Skip to content

Commit

Permalink
Added formatter for localization (axuno#207)
Browse files Browse the repository at this point in the history
  * Added `LocalizationFormatter`
  * Added `ILocalizationProvider` and a standard implemention as `LocalizationProvider`, which handles `resx` resource files
  * `SmartSettings` were exended with category `Localization`.
  * Custom `IFormatter` can now make use of localization, if needed.
  * New exception type `LocalizationFormattingException`
  * `FormattingException` now contains `InnerException` where applicable
  * Updated unit test packages to latest version
  * Updated appveyor.yml because AltCover made unit tests with resource files fail: Added CLI parameters /p:AltCoverInplace=true /p:AltCoverForce=true
  • Loading branch information
axunonb committed Mar 9, 2022
1 parent bc2e723 commit 5521cbe
Show file tree
Hide file tree
Showing 27 changed files with 2,015 additions and 19 deletions.
51 changes: 44 additions & 7 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,31 +128,68 @@ Smart.Format("{TheValue:isnull:The value is null|The value is {}}", new {TheValu
// Result: "The value is 1234"
```

### 11. Improved custom `ISource` and `IFormatter` implementations ([#180](https://github.com/axuno/SmartFormat/pull/180))
### 11. Added features to localize literals and placeholders

#### Features
* Added `LocalizationFormatter`
* Added `ILocalizationProvider` and a standard implemention as `LocalizationProvider`, which handles `resx` resource files
* `SmartSettings` were exended with category `Localization`.
* Custom `IFormatter` can now make use of localization, if needed.

#### Examples
Culture-specific results shown here are included in embedded resource files, which are omitted for brevity.

a) Localize pure literals into Spanish:
```CSharp
// culture supplied as a format option
_ = Smart.Format(culture, "{:L(en):WeTranslateText}");
// culture supplied as an argument to the formatter
var culture = CultureInfo.GetCultureInfo("es");
_ = Smart.Format(culture, "{:L:WeTranslateText}");
// result for both: "Traducimos el texto"
```
b) Localized strings may contain placeholders
```CSharp
_ = Smart.Format("{0} {1:L(es):has {:#,#} inhabitants}", "X-City", 8900000);
// result: "X-City tiene 8.900.000 habitantes"
_ = Smart.Format("{0} {1:L(es):has {:#,#} inhabitants}", "X-City", 8900000);
// result: "X-City has 8,900,000 inhabitants"
```
c) Localization can be used together with other formatters
```CSharp
_ = Smart.Format("{0:plural:{:L(en):{} item}|{:L(en):{} items}}", 0;
// result for English: 0 items
_ = Smart.Format("{0:plural:{:L(fr):{} item}|{:L(fr):{} items}}", 0;
// result for French: 0 élément
_ = Smart.Format("{0:plural:{:L(fr):{} item}|{:L(fr):{} items}}", 200;
// result for French: 200 éléments
```

### 12. Improved custom `ISource` and `IFormatter` implementations ([#180](https://github.com/axuno/SmartFormat/pull/180))
Any custom exensions can implement `IInitializer`. Then, the `SmartFormatter` will call `Initialize(SmartFormatter smartFormatter)` of the extension, before adding it to the extension list.

### 12. `IFormatter`s have one single, unique name ([#185](https://github.com/axuno/SmartFormat/pull/185))
### 13. `IFormatter`s have one single, unique name ([#185](https://github.com/axuno/SmartFormat/pull/185))
In v2, `IFormatter`s could have an unlimited number of names.
To improve performance, in v3, this is limited to one single, unique name.

### 13. JSON support ([#177](https://github.com/axuno/SmartFormat/pull/177), [#201](https://github.com/axuno/SmartFormat/pull/201))
### 14. JSON support ([#177](https://github.com/axuno/SmartFormat/pull/177), [#201](https://github.com/axuno/SmartFormat/pull/201))
Separation of `JsonSource` into 2 `ISource` extensions:
* `NewtonSoftJsonSource`
* `SystemTextJsonSource`

Fix: `NewtonSoftJsonSource` handles `null` values correctly ([#201](https://github.com/axuno/SmartFormat/pull/201))
### 14. `SmartFormatter` takes `IList<object>` parameters
### 15. `SmartFormatter` takes `IList<object>` parameters
Added support for `IList<object>` parameters to the `SmartFormatter` (thanks to [@karljj1](https://github.com/karljj1)) ([#154](https://github.com/axuno/SmartFormat/pull/154))
### 15. `SmartObjects` have been removed
### 16. `SmartObjects` have been removed
* Removed obsolete `SmartObjects` (which have been replaced by `ValueTuple`) ([`092b7b1`](https://github.com/axuno/SmartFormat/commit/092b7b1b5873301bdfeb2b62f221f936efc81430))
### 16. Bugfix for plural rule ([#182](https://github.com/axuno/SmartFormat/pull/182))
### 17. Bugfix for plural rule ([#182](https://github.com/axuno/SmartFormat/pull/182))
* Fixes #179 (DualFromZeroToTwo plural rule). Thanks to @OhSoGood

### 17. Improved parsing of HTML input ([#203](https://github.com/axuno/SmartFormat/pull/203))
### 18. Improved parsing of HTML input ([#203](https://github.com/axuno/SmartFormat/pull/203))
Introduced experimental `bool ParserSettings.ParseInputAsHtml`.
The default is `false`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ See [changelog](CHANGES.md) for changes.

<hr>

We have started to work on a new version of ```SmartFormat.Net``` and **would like to collect your input using [GitHub Discussions](https://github.com/axuno/SmartFormat/discussions/139)**.
We have started to work on a new version of ```SmartFormat.Net``` and **would like to collect your input using [GitHub Discussions](https://github.com/axuno/SmartFormat/discussions/139)**.

4 changes: 2 additions & 2 deletions appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ for:
dotnet pack SmartFormat --verbosity minimal --configuration release /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg /p:PackageOutputPath=../../artifacts /p:ContinuousIntegrationBuild=true /p:Version=$version /p:FileVersion=$versionFile
test_script:
- cmd: nuget install Appveyor.TestLogger
- cmd: dotnet test --no-build --framework net5.0 --test-adapter-path:. --logger:Appveyor SmartFormat.sln /p:configuration=release /p:AltCover=true /p:AltCoverXmlReport="coverage.xml" /p:AltCover=true /p:AltCoverStrongNameKey="..\SmartFormat\SmartFormat.snk" /p:AltCoverAssemblyExcludeFilter="SmartFormat.Tests|NUnit3.TestAdapter" /p:AltCoverLineCover="true"
- cmd: dotnet test --no-build --framework net5.0 --test-adapter-path:. --logger:Appveyor SmartFormat.sln /p:configuration=release /p:AltCover=true /p:AltCoverXmlReport="coverage.xml" /p:AltCoverInplace=true /p:AltCoverForce=true /p:AltCoverStrongNameKey="..\SmartFormat\SmartFormat.snk" /p:AltCoverAssemblyExcludeFilter="SmartFormat.Tests|NUnit3.TestAdapter" /p:AltCoverLineCover="true"
- cmd: nuget install codecov -excludeversion
- cmd: .\Codecov\Tools\win7-x86\codecov.exe -f ".\SmartFormat.Tests\coverage.net5.0.xml" -n net5.0win
artifacts:
Expand All @@ -57,5 +57,5 @@ for:
- dotnet add ./SmartFormat.Tests/SmartFormat.Tests.csproj package AltCover
- dotnet build SmartFormat.sln /verbosity:minimal /t:rebuild /p:configuration=release /nowarn:CS1591,CS0618
test_script:
- dotnet test --no-build --framework net5.0 SmartFormat.sln /p:configuration=release /p:AltCover=true /p:AltCoverXmlReport="coverage.xml" /p:AltCover=true /p:AltCoverStrongNameKey="../SmartFormat/SmartFormat.snk" /p:AltCoverAssemblyExcludeFilter="SmartFormat.Tests|NUnit3.TestAdapter|Cysharp" /p:AltCoverLineCover="true"
- dotnet test --no-build --framework net5.0 SmartFormat.sln /p:configuration=release /p:AltCover=true /p:AltCoverXmlReport="coverage.xml" /p:AltCoverInplace=true /p:AltCoverForce=true /p:AltCoverStrongNameKey="../SmartFormat/SmartFormat.snk" /p:AltCoverAssemblyExcludeFilter="SmartFormat.Tests|NUnit3.TestAdapter|Cysharp" /p:AltCoverLineCover="true"
- bash <(curl -s https://codecov.io/bash) -f ./SmartFormat.Tests/coverage.net5.0.xml -n net5.0linux
4 changes: 1 addition & 3 deletions src/Demo.NetFramework/Demo.NetFramework.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
</ItemGroup>

<ItemGroup>
<Compile Update="SmartFormatDemo.cs">
<SubType>Form</SubType>
</Compile>
<Compile Update="SmartFormatDemo.cs" />
<Compile Update="SmartFormatDemo.Designer.cs">
<DependentUpon>SmartFormatDemo.cs</DependentUpon>
</Compile>
Expand Down
207 changes: 207 additions & 0 deletions src/SmartFormat.Tests/Extensions/LocalizationFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using NUnit.Framework;
using SmartFormat.Core.Formatting;
using SmartFormat.Core.Settings;
using SmartFormat.Extensions;
using SmartFormat.Tests.Localization;

namespace SmartFormat.Tests.Extensions
{
[TestFixture]
public class LocalizationFormatterTests
{
private static SmartFormatter GetFormatterWithRegisteredResource(CaseSensitivityType caseSensitivity = CaseSensitivityType.CaseSensitive, FormatErrorAction formatErrorAction = FormatErrorAction.ThrowError)
{
var formatter = new LocalizationFormatter {CanAutoDetect = false};
var smart = Smart.CreateDefaultSmartFormat(new SmartSettings
{
CaseSensitivity = caseSensitivity,
Localization =
{
LocalizationProvider = new LocalizationProvider(true, LocTest1.ResourceManager)
{ FallbackCulture = null, ReturnNameIfNotFound = false }
},
Formatter = { ErrorAction = formatErrorAction }
});
smart.AddExtensions(formatter);

return smart;
}

[Test]
public void Missing_Format_Should_Throw()
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);
Assert.That(() => smart.Format("{:L:}"), Throws.InstanceOf<LocalizationFormattingException>().With.InnerException.InstanceOf<ArgumentException>());
}

[Test]
public void Unknown_Culture_Should_Throw()
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);
Assert.That(() => smart.Format("{:L(unknown):dummy}"), Throws.InstanceOf<LocalizationFormattingException>());
}

[Test]
public void No_Initialization_Of_LocalizationProvider_Should_Throw()
{
var smart = GetFormatterWithRegisteredResource();
var formatter = smart.GetFormatterExtension<LocalizationFormatter>();
formatter!.LocalizationProvider = null;
Assert.That(() => smart.Format("{:L(en):dummy}"), Throws.InstanceOf<LocalizationFormattingException>().With.InnerException.InstanceOf<NullReferenceException>());
}

[TestCase(FormatErrorAction.Ignore)]
[TestCase(FormatErrorAction.MaintainTokens)]
[TestCase(FormatErrorAction.OutputErrorInResult)]
[TestCase(FormatErrorAction.ThrowError)]
public void No_Localized_String_Found(FormatErrorAction errorAction)
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive, errorAction);
string? result;

switch (errorAction)
{
case FormatErrorAction.ThrowError:
Assert.That(() => smart.Format("{:L(es):NonExisting}"), Throws.InstanceOf<FormattingException>());
break;
case FormatErrorAction.OutputErrorInResult:
result = smart.Format("{:L(es):NonExisting}");
Assert.That(result, Contains.Substring("No localized string found"));
break;
case FormatErrorAction.Ignore:
result = smart.Format("{:L(es):NonExisting}");
Assert.That(result, Is.EqualTo(string.Empty));
break;
case FormatErrorAction.MaintainTokens:
result = smart.Format("{:L(es):NonExisting}");
Assert.That(result, Is.EqualTo("{:L(es):NonExisting}"));
break;
default:
throw new ArgumentOutOfRangeException(nameof(errorAction), errorAction, null);
}
}

[Test]
public void No_Localized_String_Found_With_Name_Fallback()
{
var smart = GetFormatterWithRegisteredResource();
((LocalizationProvider)smart.GetFormatterExtension<LocalizationFormatter>()!.LocalizationProvider!)
.ReturnNameIfNotFound = true;
var actual = smart.Format("{:L(es):NonExisting}");
Assert.That(actual, Is.EqualTo("NonExisting"));
}

[Test]
public void No_Localized_String_Only_In_Fallback_Culture()
{
var smart = GetFormatterWithRegisteredResource();
var actual = smart.Format(CultureInfo.GetCultureInfo("pt"), "{:L:OnlyExistForInvariantCulture}");
Assert.That(actual, Is.EqualTo("This entry only exists in the invariant culture resource"));
}

[Test]
public void Should_Use_Existing_Localized_Format()
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);
var locFormatter = smart.GetFormatterExtension<LocalizationFormatter>();
_ = smart.Format("{:L(es):WeTranslateText}");
var result = smart.Format("{:L(es):WeTranslateText}");

Assert.That(locFormatter!.LocalizedFormatCache!.Keys.Contains(result), Is.True);
}

[TestCase("{:L():WeTranslateText}", "Traducimos el texto", "es")]
[TestCase("{:L:WeTranslateText}", "Traducimos el texto", "es")]
[TestCase("{:L():WeTranslateText}", "We translate text", "")]
[TestCase("{:L:WeTranslateText}", "We translate text", "")]
public void Pure_Text_CurrentCulture(string format, string expected, string culture)
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);
CultureInfo.CurrentUICulture = culture == string.Empty ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture);

var actual = smart.Format(format);
Assert.That(actual, Is.EqualTo(expected));
}

[TestCase("{:L():WeTranslateText}", "Traducimos el texto", "es")]
[TestCase("{:L:WeTranslateText}", "Traducimos el texto", "es")]
[TestCase("{:L():WeTranslateText}", "We translate text", "")]
[TestCase("{:L:WeTranslateText}", "We translate text", "")]
public void Pure_Text_CultureByArgument(string format, string expected, string cultureString)
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);
var culture = cultureString == string.Empty ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(cultureString);

var actual = smart.Format(culture, format);
Assert.That(actual, Is.EqualTo(expected));
}

[TestCase("{:L(es):WeTranslateText}", "Traducimos el texto")]
[TestCase("{:L(en):WeTranslateText}", "We translate text")]
[TestCase("{:L(fr):WeTranslateText}", "Nous traduisons des textes")]
[TestCase("{:L(de):WeTranslateText}", "Wir übersetzen Text")]
public void Pure_Text_CultureByFormatString(string format, string expected)
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);

var actual = smart.Format(format);
Assert.That(actual, Is.EqualTo(expected));
}

// Possible, but not recommended
[TestCase("{:L(es):{0} has {1:#,#} inhabitants}", "{1} tiene {2:#,#} habitantes", "es")]
[TestCase("{:L(en):{0} has {1:#,#} inhabitants}", "{1} has {2:#,#} inhabitants", "en")]
[TestCase("{:L(fr):{0} has {1:#,#} inhabitants}", "{1} compte {2:#,#} habitants", "fr")]
[TestCase("{:L(de):{0} has {1:#,#} inhabitants}", "{1} hat {2:#,#} Einwohner", "de")]
// Best practice, because the selector is not part of the format to localize ({:#,#} applies for any selector)
[TestCase("{0} {1:L(es):has {:#,#} inhabitants}", "{1} tiene {2:#,#} habitantes", "es")]
[TestCase("{0} {1:L(en):has {:#,#} inhabitants}", "{1} has {2:#,#} inhabitants", "en")]
[TestCase("{0} {1:L(fr):has {:#,#} inhabitants}", "{1} compte {2:#,#} habitants", "fr")]
[TestCase("{0} {1:L(de):has {:#,#} inhabitants}", "{1} hat {2:#,#} Einwohner", "de")]
// Best practice (same localization as above, with different selector)
[TestCase("{2.City.Name} {2.City.Inhabitants:L(es):has {:#,#} inhabitants}", "{1} tiene {2:#,#} habitantes", "es")]
[TestCase("{2.City.Name} {2.City.Inhabitants:L(en):has {:#,#} inhabitants}", "{1} has {2:#,#} inhabitants", "en")]
[TestCase("{2.City.Name} {2.City.Inhabitants:L(fr):has {:#,#} inhabitants}", "{1} compte {2:#,#} habitants", "fr")]
[TestCase("{2.City.Name} {2.City.Inhabitants:L(de):has {:#,#} inhabitants}", "{1} hat {2:#,#} Einwohner", "de")]
public void TextWithPlaceholder_CultureByFormatString(string format, string expected, string culture)
{
var smart = GetFormatterWithRegisteredResource(CaseSensitivityType.CaseSensitive);
// number should be localized
expected = string.Format(CultureInfo.GetCultureInfo(culture), expected, new {City = new { Name = "X-City", Inhabitants = 8900000}}, "X-City", 8900000);

var actual = smart.Format(format, "X-City", 8900000, new {City = new { Name = "X-City", Inhabitants = 8900000}});
Assert.That(actual, Is.EqualTo(expected));
}

[TestCase("{0:cond:{:L:{} items}|{:L:{} item}|{:L:{} items}}", 0, "en", "0 items")]
[TestCase("{0:cond:{:L:{} items}|{:L:{} item}|{:L:{} items}}", 1, "en", "1 item")]
[TestCase("{0:cond:{:L:{} items}|{:L:{} item}|{:L:{} items}}", 200, "en", "200 items")]
[TestCase("{0:cond:{:L:{} items}|{:L:{} item}|{:L:{} items}}", 200, "es", "200 elementos")]
[TestCase("{0:cond:{:L:{} items}|{:L:{} item}|{:L:{} items}}", 200, "fr", "200 éléments")]
[TestCase("{0:cond:{:L:{} items}|{:L:{} item}|{:L:{} items}}", 200, "de", "200 Elemente")]
public void Combine_With_ConditionalFormatter(string format, int count, string cultureName, string expected)
{
// Just for demo - PluralLocalizationFormatter is the best choice for pluralization
// zero and two hundred: plural, one: singular
var smart = GetFormatterWithRegisteredResource();
var actual = smart.Format(CultureInfo.GetCultureInfo(cultureName), format, count);
Assert.That(actual, Is.EqualTo(expected));
}

[TestCase("{0:plural:{:L:{} item}|{:L:{} items}}", 0, "en", "0 items")]
[TestCase("{0:plural:{:L:{} item}|{:L:{} items}}", 1, "en", "1 item")]
[TestCase("{0:plural:{:L:{} item}|{:L:{} items}}", 200, "de", "200 Elemente")]
[TestCase("{0:plural:{:L:{} item}|{:L:{} items}}", 0, "fr", "0 élément")]
[TestCase("{0:plural:{:L:{} item}|{:L:{} items}}", 1, "fr", "1 élément")]
[TestCase("{0:plural:{:L:{} item}|{:L:{} items}}", 200, "fr", "200 éléments")]
public void Combine_With_PluralLocalizationFormatter(string format, int count, string cultureName, string expected)
{
var smart = GetFormatterWithRegisteredResource();
var actual = smart.Format(CultureInfo.GetCultureInfo(cultureName), format, count);
Assert.That(actual, Is.EqualTo(expected));
}
}
}
Loading

0 comments on commit 5521cbe

Please sign in to comment.