Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions docs/Translation-checks.md
Original file line number Diff line number Diff line change
@@ -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
<PropertyGroup>
<ReswPlusTranslationChecks>Default</ReswPlusTranslationChecks>
</PropertyGroup>
```

| 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
<PropertyGroup Condition="'$(ContinuousIntegrationBuild)' == 'true'">
<ReswPlusTranslationChecks>Strict</ReswPlusTranslationChecks>
</PropertyGroup>
```

Use `Off` when another system owns translation validation:

```xml
<PropertyGroup>
<ReswPlusTranslationChecks>Off</ReswPlusTranslationChecks>
</PropertyGroup>
```

## 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`.
2 changes: 2 additions & 0 deletions nuget/ReswPlus.targets
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
<CompilerVisibleProperty Include="UseUwp" />
<CompilerVisibleProperty Include="ReswPlusUseApplicationLanguages" />
<CompilerVisibleProperty Include="ReswPlusGenerateResourceInterfaces" />
<CompilerVisibleProperty Include="ReswPlusTranslationChecks" />
</ItemGroup>
<PropertyGroup>
<AdditionalFileItemNames>$(AdditionalFileItemNames);PRIResource</AdditionalFileItemNames>
<!-- Read the plural language from the app runtime language list instead of the .NET UI culture -->
<ReswPlusUseApplicationLanguages Condition="'$(ReswPlusUseApplicationLanguages)' == ''">false</ReswPlusUseApplicationLanguages>
<ReswPlusGenerateResourceInterfaces Condition="'$(ReswPlusGenerateResourceInterfaces)' == ''">true</ReswPlusGenerateResourceInterfaces>
<ReswPlusTranslationChecks Condition="'$(ReswPlusTranslationChecks)' == ''">Default</ReswPlusTranslationChecks>
<!-- Ask the compiler to include source-generator timings in detailed build output. -->
<ReswPlusReportGeneratorPerformance Condition="'$(ReswPlusReportGeneratorPerformance)' == ''">false</ReswPlusReportGeneratorPerformance>
<!-- Generate intermediate qps-ploc/qps-plocm resources before PRI indexing. -->
Expand Down
14 changes: 13 additions & 1 deletion src/ReswPlus.SourceGenerator/Analysis/ReswResourceAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
];

/// <inheritdoc/>
Expand Down Expand Up @@ -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);
}
Expand Down
49 changes: 48 additions & 1 deletion src/ReswPlus.SourceGenerator/Analysis/ReswResourceModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ namespace ReswPlus.SourceGenerator.Analysis;
/// </summary>
internal sealed class ReswMember
{
public ReswMember(string name, bool isPlural, IReadOnlyList<ReswEntry> entries, ReswFormatTag formatTag)
public ReswMember(
string name,
bool isPlural,
bool supportsVariants,
IReadOnlyList<string> variantIds,
IReadOnlyList<ReswEntry> entries,
ReswFormatTag formatTag)
{
Name = name;
IsPlural = isPlural;
SupportsVariants = supportsVariants;
VariantIds = variantIds;
Entries = entries;
FormatParameterCount = formatTag.ParameterCount;
FormatParameterNames = formatTag.ParameterNames;
Expand All @@ -31,6 +39,16 @@ public ReswMember(string name, bool isPlural, IReadOnlyList<ReswEntry> entries,
/// </summary>
public bool IsPlural { get; }

/// <summary>
/// Gets whether the member selects between named variants.
/// </summary>
public bool SupportsVariants { get; }

/// <summary>
/// Gets the variant identifiers declared by the resource, without the <c>Variant</c> prefix.
/// </summary>
public IReadOnlyList<string> VariantIds { get; }

/// <summary>
/// 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.
Expand Down Expand Up @@ -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)));
}
Expand All @@ -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)));
}
Expand All @@ -219,6 +241,31 @@ public static ReswResourceModel Create(ReswDocument document)
.ToArray());
}

private static IReadOnlyList<string> GetVariantIds(string memberName, IEnumerable<ReswItem> items)
{
var prefix = memberName + "_Variant";
var ids = new HashSet<string>(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;
Expand Down
Loading
Loading