From 7b4929c3beb6454ea47ee9f5993a0324355653b5 Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Sat, 22 Aug 2026 02:32:52 -0700 Subject: [PATCH] Add translation completeness diagnostics - compare default and translated resource members with safe plural and variant handling - support Off, Default, and Strict severity policies - document configuration and cover analyzer behavior with focused tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 13 +- docs/Translation-checks.md | 83 ++++++ nuget/ReswPlus.targets | 2 + .../Analysis/ReswResourceAnalyzer.cs | 14 +- .../Analysis/ReswResourceModel.cs | 49 +++- .../Analysis/ReswResourceRules.cs | 239 ++++++++++++++++-- .../Analysis/ReswTranslationChecks.cs | 23 ++ .../AnalyzerReleases.Unshipped.md | 5 + src/ReswPlus.SourceGenerator/Diagnostics.cs | 55 ++++ .../ReswPlusUnitTests/ResourceDiagnostics.cs | 4 +- tests/ReswPlusUnitTests/ReswTestHelpers.cs | 27 +- .../TranslationDiagnostics.cs | 165 ++++++++++++ 12 files changed, 645 insertions(+), 34 deletions(-) create mode 100644 docs/Translation-checks.md create mode 100644 src/ReswPlus.SourceGenerator/Analysis/ReswTranslationChecks.cs create mode 100644 tests/ReswPlusUnitTests/TranslationDiagnostics.cs diff --git a/README.md b/README.md index 0145e3d..24bdb7d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Without it, such a project is reported as `RESWP0005` and no code is generated f 🗨 [How to test localization before translations are available](docs/Pseudo-localization.md) +### Translation checks + +🗨 [How to detect incomplete or drifting translations](docs/Translation-checks.md) + ### Generator performance diagnostics To include compiler-measured source-generator timings in detailed build output, enable: @@ -152,8 +156,15 @@ ReswPlus checks the content of your `.resw` files while it generates the code, a | `RESWP0012` | A resource carries the name of a member the generated class declares itself, so it is skipped. | | `RESWP0013` | A `#Format` tag declares the same parameter name twice, so the generated method renames all but the first. | | `RESWP0014` | A `.resw` file could not be turned into code, and the rest of the project was generated without it. | +| `RESWP0016` | A resource exists in the default language but is missing from a translation. | +| `RESWP0017` | A translated resource does not exist in the default language. | +| `RESWP0018` | A translated value is identical to its default-language value. | +| `RESWP0019` | A translation has an incompatible plain, plural, or variant shape. | +| `RESWP0020` | A translation defines variants that do not exist in the default language. | + +Translation completeness is configurable with `ReswPlusTranslationChecks=Off|Default|Strict`. In the default mode, normal translation lag is informational and only defects that can break localized output are warnings. See [Translation checks](docs/Translation-checks.md) for the severity table and CI configuration. -These are reported as **warnings**, so that updating the package never breaks a build that already has an inconsistency. Escalate the ones you want to be fatal from your `.editorconfig`: +Individual diagnostics can also be configured from `.editorconfig`: ```ini dotnet_diagnostic.RESWP0006.severity = error diff --git a/docs/Translation-checks.md b/docs/Translation-checks.md new file mode 100644 index 0000000..1d3f0f5 --- /dev/null +++ b/docs/Translation-checks.md @@ -0,0 +1,83 @@ +# Translation completeness and drift checks + +ReswPlus compares each translated `.resw` file with the resource file selected for the project's default language. The checks distinguish normal translation lag from defects that can throw, display empty text, resolve ambiguously, or silently lose information. + +## Configure the checks + +`ReswPlusTranslationChecks` accepts `Off`, `Default`, or `Strict`. Values are case-insensitive, and an omitted or unrecognized value uses `Default`. + +```xml + + Default + +``` + +| Value | Behavior | +| --- | --- | +| `Off` | Disables checks that compare translations with the default language. Per-file correctness checks still run. | +| `Default` | Reports harmless incompleteness as Info and output-breaking defects as Warning. This is the default. | +| `Strict` | Promotes incompleteness to Warning and output-breaking defects to Error. | + +`Default` is intended for active development. A resource added only to the default language produces an informational diagnostic while translators catch up, so projects using `TreatWarningsAsErrors` keep building. + +Use `Strict` when localized resources are expected to be release-ready: + +```xml + + Strict + +``` + +Use `Off` when another system owns translation validation: + +```xml + + Off + +``` + +## Severity policy + +| Check | Default | Strict | +| --- | --- | --- | +| Resource exists only in the default language | Info | Warning | +| Resource exists only in a translated language | Info | Warning | +| Translation is identical to the default value | Info | Info | +| Translation defines extra variants | Info | Warning | +| Translation drops a placeholder | Warning | Error | +| Translation references an undeclared placeholder | Warning | Error | +| Composite format string is malformed | Warning | Error | +| Required plural form is missing | Warning | Error | +| Required variant is missing or the resource shape is incompatible | Warning | Error | +| Resource names conflict under case-insensitive lookup | Warning | Error | + +Plural categories are not compared directly across languages. A Polish translation, for example, legitimately has forms that English does not. ReswPlus validates each language against its own CLDR plural requirements and compares only the generated plain, plural, and variant structure. + +## What `Off` keeps checking + +`Off` suppresses diagnostics that require a comparison with the default language, including missing or extra resources, unchanged translations, dropped placeholders, and translated plural or variant drift. + +It does not suppress correctness checks within one file: + +- undeclared placeholder indexes; +- malformed composite format strings; +- conflicting resource names; +- reserved generated member names; +- duplicate `#Format` parameter names; +- required plural forms in the default-language resource. + +## Configure individual diagnostics + +Normal analyzer configuration still applies. For example, a project can suppress unchanged translations while keeping all other checks: + +```ini +dotnet_diagnostic.RESWP0018.severity = none +``` + +Or it can require missing translations without enabling every strict escalation: + +```ini +dotnet_diagnostic.RESWP0016.severity = warning +``` + +`Off` prevents cross-language diagnostics from being produced, so `.editorconfig` cannot re-enable those rules until `ReswPlusTranslationChecks` is set to `Default` or `Strict`. diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index 5d0e2cf..f99e603 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -9,12 +9,14 @@ + $(AdditionalFileItemNames);PRIResource false true + Default false diff --git a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs index 5885557..7ef9653 100644 --- a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs +++ b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs @@ -28,7 +28,14 @@ public sealed class ReswResourceAnalyzer : DiagnosticAnalyzer Diagnostics.UndeclaredFormatParameter, Diagnostics.MissingPluralForms, Diagnostics.DuplicateResource, - Diagnostics.InvalidFormatString + Diagnostics.InvalidFormatString, + Diagnostics.ReservedResourceName, + Diagnostics.DuplicateFormatParameter, + Diagnostics.MissingTranslation, + Diagnostics.TranslationWithoutDefault, + Diagnostics.UnchangedTranslation, + Diagnostics.IncompatibleTranslationShape, + Diagnostics.ExtraTranslationVariants ]; /// @@ -74,11 +81,16 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) !globalOptions.TryGetValue("build_property.ReswPlusGenerateResourceInterfaces", out var interfaceOption) || !bool.TryParse(interfaceOption, out var parsedInterfaces) || parsedInterfaces; + var translationChecks = ReswTranslationChecksParser.Parse( + globalOptions.TryGetValue("build_property.ReswPlusTranslationChecks", out var checks) + ? checks + : null); ReswResourceRules.Analyze( reswFiles, defaultLanguage, generateResourceInterfaces, + translationChecks, context.ReportDiagnostic, context.CancellationToken); } diff --git a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceModel.cs b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceModel.cs index a70f3cd..9f66fef 100644 --- a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceModel.cs +++ b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceModel.cs @@ -12,10 +12,18 @@ namespace ReswPlus.SourceGenerator.Analysis; /// internal sealed class ReswMember { - public ReswMember(string name, bool isPlural, IReadOnlyList entries, ReswFormatTag formatTag) + public ReswMember( + string name, + bool isPlural, + bool supportsVariants, + IReadOnlyList variantIds, + IReadOnlyList entries, + ReswFormatTag formatTag) { Name = name; IsPlural = isPlural; + SupportsVariants = supportsVariants; + VariantIds = variantIds; Entries = entries; FormatParameterCount = formatTag.ParameterCount; FormatParameterNames = formatTag.ParameterNames; @@ -31,6 +39,16 @@ public ReswMember(string name, bool isPlural, IReadOnlyList entries, /// public bool IsPlural { get; } + /// + /// Gets whether the member selects between named variants. + /// + public bool SupportsVariants { get; } + + /// + /// Gets the variant identifiers declared by the resource, without the Variant prefix. + /// + public IReadOnlyList VariantIds { get; } + /// /// Gets the entries the member is generated from, in document order. A member generated from a plain /// resource has a single entry, a pluralized or varianted one has an entry per form. @@ -197,6 +215,8 @@ public static ReswResourceModel Create(ReswDocument document) members.Add(new ReswMember( group.Key, group.SupportPlural, + group.SupportVariants, + GetVariantIds(group.Key, group.Items), group.Items.Where(entriesByItem.ContainsKey).Select(item => entriesByItem[item]).ToArray(), ReadFormatTag(group.Key, comment, basicItems, resourceFileName))); } @@ -208,6 +228,8 @@ public static ReswResourceModel Create(ReswDocument document) members.Add(new ReswMember( item.Key, isPlural: false, + supportsVariants: false, + variantIds: [], [entriesByItem[item]], ReadFormatTag(item.Key, item.Comment, basicItems, resourceFileName))); } @@ -219,6 +241,31 @@ public static ReswResourceModel Create(ReswDocument document) .ToArray()); } + private static IReadOnlyList GetVariantIds(string memberName, IEnumerable items) + { + var prefix = memberName + "_Variant"; + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in items) + { + if (!item.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var suffix = item.Key.Substring(prefix.Length); + var separator = suffix.IndexOf('_'); + var id = separator < 0 ? suffix : suffix.Substring(0, separator); + + if (id.Length > 0) + { + ids.Add(id); + } + } + + return ids.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToArray(); + } + private static bool HasFormatTag(string? comment) { return ReswClassGenerator.ParseTag(comment).format is not null; diff --git a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs index 7a26770..17f2d7b 100644 --- a/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs +++ b/src/ReswPlus.SourceGenerator/Analysis/ReswResourceRules.cs @@ -15,9 +15,8 @@ namespace ReswPlus.SourceGenerator.Analysis; /// Implements the rules reported on the content of the .resw files of a project. /// /// -/// Every rule is reported as a warning rather than an error: raising the severity would break the build of every -/// project that already has an inconsistency the moment it updates the package. Projects that want a rule to be -/// fatal can escalate it through .editorconfig. +/// Translation lag is informational by default, while problems that can break localized output are warnings. +/// Strict translation checks promote incomplete translations to warnings and output-breaking problems to errors. /// /// The rules err on the side of not firing. A noisy analyzer gets disabled wholesale, taking the valuable rules /// with it, so a rule that cannot decide stays silent. @@ -36,12 +35,14 @@ internal static class ReswResourceRules /// The .resw files of the project, with their content. /// The default language of the project, if it declares one. /// Whether injectable resource interfaces and providers are generated. + /// How diagnostics comparing translations with the default language are reported. /// The callback invoked for every problem found. /// The token used to cancel the operation. public static void Analyze( IReadOnlyList<(string Path, SourceText Text)> reswFiles, string? defaultLanguage, bool generateResourceInterfaces, + ReswTranslationChecks translationChecks, Action reportDiagnostic, CancellationToken cancellationToken) { @@ -65,7 +66,7 @@ public static void Analyze( var defaultModel = ReswResourceModel.Create(defaultDocument); - AnalyzeDocument(defaultModel, defaultModel, generateResourceInterfaces, reportDiagnostic); + AnalyzeDocument(defaultModel, defaultModel, generateResourceInterfaces, translationChecks, reportDiagnostic); foreach (var path in group) { @@ -76,7 +77,12 @@ public static void Analyze( continue; } - AnalyzeDocument(ReswResourceModel.Create(document), defaultModel, generateResourceInterfaces, reportDiagnostic); + AnalyzeDocument( + ReswResourceModel.Create(document), + defaultModel, + generateResourceInterfaces, + translationChecks, + reportDiagnostic); } } } @@ -85,13 +91,26 @@ private static void AnalyzeDocument( ReswResourceModel model, ReswResourceModel defaultModel, bool generateResourceInterfaces, + ReswTranslationChecks translationChecks, Action reportDiagnostic) { - ReportDuplicateMembers(model, reportDiagnostic); - ReportReservedNames(model, generateResourceInterfaces, reportDiagnostic); - ReportDuplicateFormatParameters(model, reportDiagnostic); - ReportMissingPluralForms(model, defaultModel, reportDiagnostic); - ReportFormattingProblems(model, defaultModel, reportDiagnostic); + ReportDuplicateMembers(model, translationChecks, reportDiagnostic); + ReportReservedNames(model, generateResourceInterfaces, translationChecks, reportDiagnostic); + ReportDuplicateFormatParameters(model, translationChecks, reportDiagnostic); + + var isDefaultLanguage = ReferenceEquals(model, defaultModel); + + if (!isDefaultLanguage && translationChecks != ReswTranslationChecks.Off) + { + ReportTranslationDifferences(model, defaultModel, translationChecks, reportDiagnostic); + } + + if (isDefaultLanguage || translationChecks != ReswTranslationChecks.Off) + { + ReportMissingPluralForms(model, defaultModel, translationChecks, reportDiagnostic); + } + + ReportFormattingProblems(model, defaultModel, translationChecks, reportDiagnostic); } /// @@ -104,6 +123,7 @@ private static void AnalyzeDocument( private static void ReportReservedNames( ReswResourceModel model, bool generateResourceInterfaces, + ReswTranslationChecks translationChecks, Action reportDiagnostic) { var className = Path.GetFileNameWithoutExtension(model.Document.Path); @@ -115,11 +135,11 @@ private static void ReportReservedNames( continue; } - reportDiagnostic(Diagnostic.Create( + ReportDiagnostic(reportDiagnostic, translationChecks, Diagnostics.ReservedResourceName, member.Entries[0].Location, member.Entries[0].Key, - Path.GetFileName(model.Document.Path))); + Path.GetFileName(model.Document.Path)); } } @@ -131,7 +151,10 @@ private static void ReportReservedNames( /// pluralized or varianted resource, and renames it when the tag already uses its name, which is a /// conflict the author of the tag did not create and is not asked to resolve. /// - private static void ReportDuplicateFormatParameters(ReswResourceModel model, Action reportDiagnostic) + private static void ReportDuplicateFormatParameters( + ReswResourceModel model, + ReswTranslationChecks translationChecks, + Action reportDiagnostic) { foreach (var member in model.Members) { @@ -144,11 +167,11 @@ private static void ReportDuplicateFormatParameters(ReswResourceModel model, Act continue; } - reportDiagnostic(Diagnostic.Create( + ReportDiagnostic(reportDiagnostic, translationChecks, Diagnostics.DuplicateFormatParameter, member.Entries[0].Location, member.Name, - name)); + name); } } } @@ -161,7 +184,10 @@ private static void ReportDuplicateFormatParameters(ReswResourceModel model, Act /// case resolve to the same string at runtime. A plain resource can also conflict with a pluralized or /// varianted one, in which case the generated members collide and the project no longer compiles. /// - private static void ReportDuplicateMembers(ReswResourceModel model, Action reportDiagnostic) + private static void ReportDuplicateMembers( + ReswResourceModel model, + ReswTranslationChecks translationChecks, + Action reportDiagnostic) { var membersByName = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -169,11 +195,11 @@ private static void ReportDuplicateMembers(ReswResourceModel model, Action - private static void ReportMissingPluralForms(ReswResourceModel model, ReswResourceModel defaultModel, Action reportDiagnostic) + private static void ReportMissingPluralForms( + ReswResourceModel model, + ReswResourceModel defaultModel, + ReswTranslationChecks translationChecks, + Action reportDiagnostic) { if (model.Document.Language is not { Length: > 0 } language || PluralFormsRetriever.RetrievePluralFormForLanguage(language) is not { } pluralForm) @@ -225,12 +255,12 @@ private static void ReportMissingPluralForms(ReswResourceModel model, ReswResour continue; } - reportDiagnostic(Diagnostic.Create( + ReportDiagnostic(reportDiagnostic, translationChecks, Diagnostics.MissingPluralForms, declension.Location, declension.Prefix, string.Join(", ", missingCategories.Select(category => $"'_{category}'")), - language)); + language); } } } @@ -245,7 +275,11 @@ private static void ReportMissingPluralForms(ReswResourceModel model, ReswResour /// only one that determines whether, and with how many arguments, a resource is formatted. /// /// The callback invoked for every problem found. - private static void ReportFormattingProblems(ReswResourceModel model, ReswResourceModel defaultModel, Action reportDiagnostic) + private static void ReportFormattingProblems( + ReswResourceModel model, + ReswResourceModel defaultModel, + ReswTranslationChecks translationChecks, + Action reportDiagnostic) { var isDefaultLanguage = ReferenceEquals(model, defaultModel); @@ -261,26 +295,32 @@ private static void ReportFormattingProblems(ReswResourceModel model, ReswResour { if (!CompositeFormatString.TryGetArgumentIndexes(entry.Value, out var indexes)) { - reportDiagnostic(Diagnostic.Create(Diagnostics.InvalidFormatString, entry.Location, entry.Key)); + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.InvalidFormatString, + entry.Location, + entry.Key); continue; } if (TryGetUndeclaredIndex(indexes, defaultMember.FormatParameterCount, out var undeclaredIndex)) { - reportDiagnostic(Diagnostic.Create( + ReportDiagnostic(reportDiagnostic, translationChecks, Diagnostics.UndeclaredFormatParameter, entry.Location, entry.Key, undeclaredIndex, - defaultMember.FormatParameterCount)); + defaultMember.FormatParameterCount); continue; } // Comparing a translation against itself would always match, and a resource that only exists in // the default language has nothing to be compared with. - if (isDefaultLanguage || + if (translationChecks == ReswTranslationChecks.Off || + isDefaultLanguage || !defaultModel.TryGetEntry(entry.Key, out var defaultEntry) || !CompositeFormatString.TryGetArgumentIndexes(defaultEntry.Value, out var defaultIndexes)) { @@ -298,15 +338,158 @@ private static void ReportFormattingProblems(ReswResourceModel model, ReswResour continue; } - reportDiagnostic(Diagnostic.Create( + ReportDiagnostic(reportDiagnostic, translationChecks, Diagnostics.PlaceholderMismatch, entry.Location, entry.Key, - DescribePlaceholders(missingPlaceholders))); + DescribePlaceholders(missingPlaceholders)); + } + } + } + + private static void ReportTranslationDifferences( + ReswResourceModel model, + ReswResourceModel defaultModel, + ReswTranslationChecks translationChecks, + Action reportDiagnostic) + { + var language = model.Document.Language ?? Path.GetFileName(Path.GetDirectoryName(model.Document.Path)); + + foreach (var defaultMember in defaultModel.Members) + { + if (!model.TryGetMember(defaultMember.Name, out var translatedMember)) + { + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.MissingTranslation, + defaultMember.Entries[0].Location, + defaultMember.Name, + language); + + continue; + } + + if (defaultMember.IsPlural != translatedMember.IsPlural || + defaultMember.SupportsVariants != translatedMember.SupportsVariants) + { + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.IncompatibleTranslationShape, + translatedMember.Entries[0].Location, + defaultMember.Name, + language, + "uses a different plain, plural, or variant structure than the default-language resource"); + + continue; + } + + var missingVariants = defaultMember.VariantIds + .Where(id => !translatedMember.VariantIds.Contains(id, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + if (missingVariants.Length > 0) + { + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.IncompatibleTranslationShape, + translatedMember.Entries[0].Location, + defaultMember.Name, + language, + $"does not define the required variant(s) {DescribeValues(missingVariants)}"); + } + + var extraVariants = translatedMember.VariantIds + .Where(id => !defaultMember.VariantIds.Contains(id, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + if (extraVariants.Length > 0) + { + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.ExtraTranslationVariants, + translatedMember.Entries[0].Location, + defaultMember.Name, + language, + DescribeValues(extraVariants)); + } + } + + foreach (var translatedMember in model.Members) + { + if (!defaultModel.TryGetMember(translatedMember.Name, out _)) + { + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.TranslationWithoutDefault, + translatedMember.Entries[0].Location, + translatedMember.Name, + language); + } + } + + foreach (var entry in model.Members.SelectMany(member => member.Entries)) + { + if (defaultModel.TryGetEntry(entry.Key, out var defaultEntry) && + string.Equals(entry.Value, defaultEntry.Value, StringComparison.Ordinal)) + { + ReportDiagnostic( + reportDiagnostic, + translationChecks, + Diagnostics.UnchangedTranslation, + entry.Location, + entry.Key, + language); } } } + private static string DescribeValues(IEnumerable values) + { + return string.Join(", ", values.Select(value => $"'{value}'")); + } + + private static void ReportDiagnostic( + Action reportDiagnostic, + ReswTranslationChecks translationChecks, + DiagnosticDescriptor descriptor, + Location location, + params object[] messageArgs) + { + var severity = GetSeverity(descriptor, translationChecks); + + reportDiagnostic(Diagnostic.Create( + descriptor, + location, + severity, + additionalLocations: null, + properties: null, + messageArgs)); + } + + private static DiagnosticSeverity GetSeverity( + DiagnosticDescriptor descriptor, + ReswTranslationChecks translationChecks) + { + if (translationChecks != ReswTranslationChecks.Strict) + { + return descriptor.DefaultSeverity; + } + + return descriptor.Id switch + { + "RESWP0016" or "RESWP0017" or "RESWP0020" => DiagnosticSeverity.Warning, + "RESWP0018" => DiagnosticSeverity.Info, + "RESWP0006" or "RESWP0007" or "RESWP0008" or "RESWP0009" or "RESWP0010" or + "RESWP0012" or "RESWP0019" => DiagnosticSeverity.Error, + _ => descriptor.DefaultSeverity, + }; + } + /// /// Looks for a placeholder that has no matching argument in the generated call to string.Format. /// diff --git a/src/ReswPlus.SourceGenerator/Analysis/ReswTranslationChecks.cs b/src/ReswPlus.SourceGenerator/Analysis/ReswTranslationChecks.cs new file mode 100644 index 0000000..2d833b5 --- /dev/null +++ b/src/ReswPlus.SourceGenerator/Analysis/ReswTranslationChecks.cs @@ -0,0 +1,23 @@ +using System; + +namespace ReswPlus.SourceGenerator.Analysis; + +/// +/// Controls diagnostics that compare translated resources with the default language. +/// +internal enum ReswTranslationChecks +{ + Off, + Default, + Strict, +} + +internal static class ReswTranslationChecksParser +{ + public static ReswTranslationChecks Parse(string? value) + { + return Enum.TryParse(value, ignoreCase: true, out ReswTranslationChecks parsed) + ? parsed + : ReswTranslationChecks.Default; + } +} diff --git a/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md b/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md index 291f615..6108aa0 100644 --- a/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -9,3 +9,8 @@ RESWP0012 | Resources | Warning | A resource carries the name of a member RESWP0013 | Resources | Warning | A #Format tag declares two parameters of the same name. RESWP0014 | Resources | Warning | A resource file could not be turned into code. RESWP0015 | Resources | Error | The plural support of the project could not be generated. +RESWP0016 | Resources | Info | A resource from the default language is missing from a translation. +RESWP0017 | Resources | Info | A translated resource does not exist in the default language. +RESWP0018 | Resources | Info | A translated value is identical to its default-language value. +RESWP0019 | Resources | Warning | A translation cannot serve the resource shape generated from the default language. +RESWP0020 | Resources | Info | A translation defines variants that do not exist in the default language. diff --git a/src/ReswPlus.SourceGenerator/Diagnostics.cs b/src/ReswPlus.SourceGenerator/Diagnostics.cs index 8d21903..0b9d4f7 100644 --- a/src/ReswPlus.SourceGenerator/Diagnostics.cs +++ b/src/ReswPlus.SourceGenerator/Diagnostics.cs @@ -217,6 +217,61 @@ internal static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); + /// + /// RESWP0016: a resource from the default language has no translation. + /// + public static readonly DiagnosticDescriptor MissingTranslation = new( + "RESWP0016", + "Resource is not translated", + "The resource '{0}' exists only in the default language and is missing from the '{1}' translation", + ResourcesCategory, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + /// RESWP0017: a translated resource no longer exists in the default language. + /// + public static readonly DiagnosticDescriptor TranslationWithoutDefault = new( + "RESWP0017", + "Translated resource has no default value", + "The resource '{0}' exists in the '{1}' translation but not in the default language", + ResourcesCategory, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + /// RESWP0018: a translation is textually identical to its default value. + /// + public static readonly DiagnosticDescriptor UnchangedTranslation = new( + "RESWP0018", + "Translation is unchanged", + "The value of the resource '{0}' in the '{1}' translation is identical to the default-language value", + ResourcesCategory, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + /// RESWP0019: a translation cannot serve every generated plural or variant lookup of the default resource. + /// + public static readonly DiagnosticDescriptor IncompatibleTranslationShape = new( + "RESWP0019", + "Translation has an incompatible resource shape", + "The resource '{0}' in the '{1}' translation {2}", + ResourcesCategory, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// RESWP0020: a translation declares variants that the default language does not use. + /// + public static readonly DiagnosticDescriptor ExtraTranslationVariants = new( + "RESWP0020", + "Translation has extra variants", + "The resource '{0}' in the '{1}' translation defines the extra variant(s) {2}", + ResourcesCategory, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + /// /// Returns the descriptor of a diagnostic from its identifier. /// diff --git a/tests/ReswPlusUnitTests/ResourceDiagnostics.cs b/tests/ReswPlusUnitTests/ResourceDiagnostics.cs index c230a60..319f297 100644 --- a/tests/ReswPlusUnitTests/ResourceDiagnostics.cs +++ b/tests/ReswPlusUnitTests/ResourceDiagnostics.cs @@ -72,7 +72,7 @@ public void PlaceholderMismatch_IsNotReportedForResourcesMissingFromATranslation ("en-US", ReswTestHelpers.CreateResw(("Greeting", "Hello {0}", "#Format[String name]"))), ("fr", ReswTestHelpers.CreateResw(("Other", "Autre", null)))); - Assert.Empty(diagnostics); + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "RESWP0006"); } [Fact] @@ -294,7 +294,7 @@ public void MissingPluralForms_AreNotReportedForAResourceThatOnlyExistsInATransl ("en-US", ReswTestHelpers.CreateResw(("Welcome", "Welcome!", null))), ("pl", ReswTestHelpers.CreateResw(("Orphan_One", "{0} plik", null)))); - Assert.Empty(diagnostics); + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "RESWP0008"); } [Fact] diff --git a/tests/ReswPlusUnitTests/ReswTestHelpers.cs b/tests/ReswPlusUnitTests/ReswTestHelpers.cs index 5f81f12..e02f2fb 100644 --- a/tests/ReswPlusUnitTests/ReswTestHelpers.cs +++ b/tests/ReswPlusUnitTests/ReswTestHelpers.cs @@ -118,6 +118,7 @@ public static IReadOnlyList Analyze( documents, defaultLanguage, generateResourceInterfaces, + ReswTranslationChecks.Default, diagnostics.Add, CancellationToken.None); @@ -131,15 +132,39 @@ public static IReadOnlyList Analyze( /// The files of the project, as language folder name and content pairs. /// The diagnostics reported for those files. public static async Task> RunAnalyzerAsync(params (string Language, string Content)[] files) + { + return await RunAnalyzerAsyncWithOptions(defaultLanguage: null, translationChecks: null, files); + } + + /// + /// Runs the resource analyzer through the compiler with explicit project translation options. + /// + public static async Task> RunAnalyzerAsyncWithOptions( + string? defaultLanguage, + string? translationChecks, + params (string Language, string Content)[] files) { var additionalFiles = files .Select(file => (AdditionalText)new InMemoryAdditionalText(GetPath(file.Language), file.Content)) .ToImmutableArray(); var compilation = CSharpCompilation.Create("TestProject"); + var globalOptions = new Dictionary(AnalyzerConfigOptions.KeyComparer); + + if (defaultLanguage is not null) + { + globalOptions["build_property.DefaultLanguage"] = defaultLanguage; + } + + if (translationChecks is not null) + { + globalOptions["build_property.ReswPlusTranslationChecks"] = translationChecks; + } return await compilation - .WithAnalyzers([new ReswResourceAnalyzer()], new AnalyzerOptions(additionalFiles)) + .WithAnalyzers( + [new ReswResourceAnalyzer()], + new AnalyzerOptions(additionalFiles, new TestAnalyzerConfigOptionsProvider(globalOptions))) .GetAnalyzerDiagnosticsAsync(CancellationToken.None); } diff --git a/tests/ReswPlusUnitTests/TranslationDiagnostics.cs b/tests/ReswPlusUnitTests/TranslationDiagnostics.cs new file mode 100644 index 0000000..e78e31c --- /dev/null +++ b/tests/ReswPlusUnitTests/TranslationDiagnostics.cs @@ -0,0 +1,165 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace ReswPlusUnitTests; + +public class TranslationDiagnostics +{ + private static string DefaultResources => ReswTestHelpers.CreateResw( + ("Welcome", "Welcome", null), + ("OnlyDefault", "Only in the default language", null), + ("Same", "ReswPlus", null), + ("Message", "Hello {0}", "#Format[String name]")); + + private static string FrenchResources => ReswTestHelpers.CreateResw( + ("Welcome", "Bienvenue", null), + ("OnlyFrench", "Seulement en français", null), + ("Same", "ReswPlus", null), + ("Message", "Bonjour", null)); + + [Fact] + public async Task DefaultModeReportsHarmlessDriftAsInformationAndCriticalDriftAsWarning() + { + var diagnostics = await Analyze("Default"); + + AssertDiagnostic(diagnostics, "RESWP0016", DiagnosticSeverity.Info); + AssertDiagnostic(diagnostics, "RESWP0017", DiagnosticSeverity.Info); + AssertDiagnostic(diagnostics, "RESWP0018", DiagnosticSeverity.Info); + AssertDiagnostic(diagnostics, "RESWP0006", DiagnosticSeverity.Warning); + } + + [Fact] + public async Task StrictModeEscalatesHarmlessDriftToWarningsAndCriticalDriftToErrors() + { + var diagnostics = await Analyze("Strict"); + + AssertDiagnostic(diagnostics, "RESWP0016", DiagnosticSeverity.Warning); + AssertDiagnostic(diagnostics, "RESWP0017", DiagnosticSeverity.Warning); + AssertDiagnostic(diagnostics, "RESWP0018", DiagnosticSeverity.Info); + AssertDiagnostic(diagnostics, "RESWP0006", DiagnosticSeverity.Error); + } + + [Fact] + public async Task OffDisablesCrossLanguageChecksButKeepsPerFileValidation() + { + var malformedFrench = ReswTestHelpers.CreateResw( + ("Welcome", "Bienvenue", null), + ("Message", "Bonjour {1}", null)); + + var diagnostics = await ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + "Off", + ("en-US", DefaultResources), + ("fr", malformedFrench)); + + Assert.DoesNotContain(diagnostics, diagnostic => + diagnostic.Id is "RESWP0006" or "RESWP0008" or "RESWP0016" or "RESWP0017" or + "RESWP0018" or "RESWP0019" or "RESWP0020"); + AssertDiagnostic(diagnostics, "RESWP0007", DiagnosticSeverity.Warning); + } + + [Fact] + public async Task AnUnknownModeUsesDefaultBehavior() + { + var diagnostics = await Analyze("unexpected"); + + AssertDiagnostic(diagnostics, "RESWP0016", DiagnosticSeverity.Info); + AssertDiagnostic(diagnostics, "RESWP0006", DiagnosticSeverity.Warning); + } + + [Fact] + public async Task AnOmittedModeUsesDefaultBehavior() + { + var diagnostics = await ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + translationChecks: null, + ("en-US", DefaultResources), + ("fr", FrenchResources)); + + AssertDiagnostic(diagnostics, "RESWP0016", DiagnosticSeverity.Info); + AssertDiagnostic(diagnostics, "RESWP0006", DiagnosticSeverity.Warning); + } + + [Fact] + public async Task StrictModeEscalatesCriticalPerFileValidation() + { + var malformed = ReswTestHelpers.CreateResw( + ("Message", "Hello {1}", "#Format[String name]")); + + var diagnostics = await ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + "Strict", + ("en-US", malformed)); + + AssertDiagnostic(diagnostics, "RESWP0007", DiagnosticSeverity.Error); + } + + [Fact] + public async Task MissingVariantsAreCriticalAndExtraVariantsAreAdvisory() + { + var defaults = ReswTestHelpers.CreateResw( + ("Greeting_Variant1", "Hello", "#Format[Variant kind]"), + ("Greeting_Variant2", "Hi", null)); + var translation = ReswTestHelpers.CreateResw( + ("Greeting_Variant1", "Bonjour", null), + ("Greeting_Variant3", "Salut", null)); + + var normal = await ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + "Default", + ("en-US", defaults), + ("fr", translation)); + var strict = await ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + "Strict", + ("en-US", defaults), + ("fr", translation)); + + AssertDiagnostic(normal, "RESWP0019", DiagnosticSeverity.Warning); + AssertDiagnostic(normal, "RESWP0020", DiagnosticSeverity.Info); + AssertDiagnostic(strict, "RESWP0019", DiagnosticSeverity.Error); + AssertDiagnostic(strict, "RESWP0020", DiagnosticSeverity.Warning); + } + + [Fact] + public async Task DifferentLanguageSpecificPluralFormsAreNotShapeDrift() + { + var defaults = ReswTestHelpers.CreateResw( + ("Items_One", "{0} item", "#Format[Plural Int count]"), + ("Items_Other", "{0} items", null)); + var polish = ReswTestHelpers.CreateResw( + ("Items_One", "{0} element", null), + ("Items_Few", "{0} elementy", null), + ("Items_Many", "{0} elementów", null), + ("Items_Other", "{0} elementu", null)); + + var diagnostics = await ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + "Default", + ("en-US", defaults), + ("pl", polish)); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id is "RESWP0019" or "RESWP0020"); + } + + private static Task> Analyze(string mode) + { + return ReswTestHelpers.RunAnalyzerAsyncWithOptions( + "en-US", + mode, + ("en-US", DefaultResources), + ("fr", FrenchResources)); + } + + private static void AssertDiagnostic( + System.Collections.Generic.IEnumerable diagnostics, + string id, + DiagnosticSeverity severity) + { + var diagnostic = Assert.Single(diagnostics, candidate => candidate.Id == id); + + Assert.Equal(severity, diagnostic.Severity); + } +}