diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0099f60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: Build, Test and Release + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + release: + types: [ released ] + +env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 100 + + - name: Setup .NET + uses: actions/setup-dotnet@v5.1.0 + with: + dotnet-version: 10.0.x + + - name: Install dependencies + working-directory: ./src + run: dotnet restore + + - name: Build + working-directory: ./src + run: | + dotnet build --configuration Release --no-restore + dotnet pack -c Release Jeffijoe.MessageFormat/Jeffijoe.MessageFormat.csproj + + - name: Test + working-directory: ./src + run: dotnet test --no-restore --verbosity normal + + - name: Publish to NuGet + if: github.event_name == 'release' # Only publish on Release creation. + working-directory: ./src + run: | + dotnet nuget push Jeffijoe.MessageFormat/**/*.nupkg --api-key ${{ secrets.NUGET_JEFFIJOE_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/.gitignore b/.gitignore index 9df2302..45158ca 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ obj bin imagecache /src/.vs +src/.idea/.idea.MessageFormat/.idea/workspace.xml +.DS_Store +BenchmarkDotNet.Artifacts/ diff --git a/README.md b/README.md index 4a45314..d9ce36f 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ #### - better UI strings. - [![Build status](https://ci.appveyor.com/api/projects/status/9g7dplst1vyibc3e?svg=true)](https://ci.appveyor.com/project/jeffijoe/messageformat-net) [![Join the chat at https://gitter.im/jeffijoe/messageformat.net](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jeffijoe/messageformat.net?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Build & Test](https://github.com/jeffijoe/messageformat.net/actions/workflows/ci.yml/badge.svg)](https://github.com/jeffijoe/messageformat.net/actions/workflows/ci.yml) This is an implementation of the ICU Message Format in .NET. For official information about the format, go to: -http://userguide.icu-project.org/formatparse/messages +https://unicode-org.github.io/icu/userguide/format_parse/messages/ ## Quickstart @@ -13,7 +13,7 @@ http://userguide.icu-project.org/formatparse/messages var mf = new MessageFormatter(); var str = @"You have {notifications, plural, - zero {no notifications} + =0 {no notifications} one {one notification} =42 {a universal amount of notifications} other {# notifications} @@ -60,20 +60,19 @@ Install-Package MessageFormat ## Features * **It's fast.** Everything is hand-written; no parser-generators, *not even regular expressions*. -* **It's portable.** The library is a PCL, and has just a single dependency ([Portable.ConcurrentDictionary](https://www.nuget.org/packages/Portable.ConcurrentDictionary/) for thread safety) - other than that the only reference is the standard `.NET` in PCL's. -* **It's compatible with other implementations.** I've been peeking a bit at the [MessageFormat.js][0] library to make sure - the results would be the same. +* **It's portable.** The library is targeting **.NET Standard 2.0**. * **It's (relatively) small**. For a .NET library, ~25kb is not a lot. * **It's very white-space tolerant.** You can structure your blocks so they are more readable - look at the example above. * **Nesting is supported.** You can nest your blocks as you please, there's no special structure required to do this, just ensure your braces match. * **Adding your own formatters.** I don't know why you would need to, but if you want, you can add your own formatters, and take advantage of the code in my base classes to help you parse patterns. Look at the source, this is how I implemented the built-in formatters. -* **Exceptions make atleast a little sense.** When exceptions are thrown due to a bad pattern, the exception should include useful information. +* **Exceptions make at least a little sense.** When exceptions are thrown due to a bad pattern, the exception should include useful information. * **There are unit tests.** Run them yourself if you want, they're using XUnit. * **Built-in cache.** If you are formatting messages in a tight loop, with different data for each iteration, and if you are reusing the same instance of `MessageFormatter`, the formatter will cache the tokens of each pattern (nested, too), so it won't have to spend CPU time to parse out literals every time. I benchmarked it, and on my monster machine, it didn't make much of a difference (10000 iterations). +* **Built-in pluralization formatters**. Generated from the [CLDR pluralization rule data][plural-cldr]. ## Performance @@ -84,21 +83,73 @@ and about 3 seconds (3236ms) without it. **These results are with a debug build, ## Supported formats -Basically, it should be able to do anything that [MessageFormat.js][0] can do. +MessageFormat.NET supports the most commonly used formats: * Select Format: `{gender, select, male{He likes} female{She likes} other{They like}} cheeseburgers` -* Plural Format: `There {msgCount, plural, zero {are no unread messages} one {is 1 unread message} other{are # unread messages}}.` (where `#` is the actual number, with the offset (if any) subtracted). +* Plural Format: `There {msgCount, plural, =0 {are no unread messages} one {is 1 unread message} other{are # unread messages}}.` (where `#` is the actual number, with the offset (if any) subtracted). +* Ordinal Format: `You are the {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} person in line.` * Simple variable replacement: `Your name is {name}` +* Numbers: `Your age is {age, number}` +* Dates: `You were born {birthday, date}` +* Time: `The time is {now, time}` + +You can also specify a _predefined style_, for example `{birthday, date, short}`. The supported predefined styles are: + +* For the `number` format: `integer`, `currency`, `percent` +* For the `date` format: `short`, `full` +* For the `time` format: `short`, `medium` + +These are currently mapped to the built-in .NET format specifiers. This package does not ship with +any locale data beyond the pluralizers that are generated based on [CLDR data][plural-cldr], so if you wish +to provide your own localized formatting, read the section below. + +## Customize formatting + +If you wish to control exactly how `number`, `date` and `time` are formatted, you can either: +* Derive `CustomValueFormatter` and override the format methods +* Instantiate a `CustomValueFormatters` and assign a lambda to the desired properties +Then pass it in as the `customValueFormatter` parameter to `new MessageFormatter`. + +**Example**: A custom formatter that allows the use of .NET's formatting tokens. This is for illustration purposes only and +is not recommended for use in real apps. + +```csharp +// This is using the lambda-based approach. +var custom = new CustomValueFormatters +{ + // The formatter must set the `formatted` out parameter and return `true` + // If the formatter returns `false`, the built-in formatting is used. + Number = (CultureInfo _, object? value, string? style, out string? formatted) => + { + formatted = string.Format($"{{0:{style}}}", value); + return true; + } +}; + +// Create a MessageFormatter with the custom value formatter. +var formatter = new MessageFormatter(customValueFormatter: custom); + +// Format a message, passing the culture to FormatMessage. +var message = formatter.FormatMessage("{value, number, $0.0}", new { value = 23 }, + CultureInfo.GetCultureInfo("en-US")); +// "$23.0" +``` ## Adding your own pluralizer functions +> Since MessageFormat 5.0, pluralizers based on the [official CLDR data][plural-cldr] ship +> with the package, so this is no longer needed except when overriding specific custom locales. + Same thing as with [MessageFormat.js][0], you can add your own pluralizer function. -The `Pluralizers` property is a `IDictionary`, so you can remove the built-in -ones if you want. +The `CardinalPluralizers` property is a `IDictionary` that starts empty, along +with `OrdinalPluralizers` for ordinal numbers. + +Adding to these Dictionaries will take precedence over the CLDR data for exact matches on +the input locales. ````csharp var mf = new MessageFormatter(); -mf.Pluralizers.Add("", n => { +mf.CardinalPluralizers.Add("", n => { // ´n´ is the number being pluralized. if(n == 0) return "zero"; @@ -108,16 +159,14 @@ mf.Pluralizers.Add("", n => { }); ```` -There's no restrictions on what strings you may return, nor what strings +There are no restrictions on what strings you may return, nor what strings you may use in your pluralization block. ````csharp -var mf = new MessageFormatter(true, "en"); // true = use cache -mf.Pluralizers["en"] = n => +var mf = new MessageFormatter(); // uses cache by default +mf.CardinalPluralizers["en"] = n => { // ´n´ is the number being pluralized. - if (n == 0) - return "zero"; if (n == 1) return "one"; if (n > 1000) @@ -127,7 +176,7 @@ mf.Pluralizers["en"] = n => mf.FormatMessage("You have {number, plural, thatsalot {a shitload of notifications} other {# notifications}}", new Dictionary{ {"number", 1001} -}); +}, CultureInfo.GetCultureInfo("en")); ```` ## Escaping literals @@ -149,12 +198,6 @@ be (somewhat) compatible with his. If you have issues with the library, and the exception makes no sense, please open an issue and include your message, as well as the data you used. -# Todo - -* Built-in locales (currently only `en` is added per default). - -Don't expect this in the near future - you're welcome to submit a PR. :) - # Author I'm Jeff Hansen, a software developer who likes to fiddle with string parsing when it is not too difficult. @@ -164,3 +207,4 @@ You can find me on Twitter: [@jeffijoe][1]. [0]: https://github.com/SlexAxton/messageformat.js [1]: https://twitter.com/jeffijoe + [plural-cldr]: https://cldr.unicode.org/index/downloads diff --git a/src/.idea/.idea.MessageFormat/.idea/.name b/src/.idea/.idea.MessageFormat/.idea/.name new file mode 100644 index 0000000..662f195 --- /dev/null +++ b/src/.idea/.idea.MessageFormat/.idea/.name @@ -0,0 +1 @@ +MessageFormat \ No newline at end of file diff --git a/src/.idea/.idea.MessageFormat/.idea/codeStyles/codeStyleConfig.xml b/src/.idea/.idea.MessageFormat/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/src/.idea/.idea.MessageFormat/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/.idea/.idea.MessageFormat/.idea/encodings.xml b/src/.idea/.idea.MessageFormat/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/src/.idea/.idea.MessageFormat/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/.idea/.idea.MessageFormat/.idea/indexLayout.xml b/src/.idea/.idea.MessageFormat/.idea/indexLayout.xml new file mode 100644 index 0000000..074ca59 --- /dev/null +++ b/src/.idea/.idea.MessageFormat/.idea/indexLayout.xml @@ -0,0 +1,10 @@ + + + + + ../../messageformat-dotnet + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.MessageFormat/.idea/projectSettingsUpdater.xml b/src/.idea/.idea.MessageFormat/.idea/projectSettingsUpdater.xml new file mode 100644 index 0000000..64af657 --- /dev/null +++ b/src/.idea/.idea.MessageFormat/.idea/projectSettingsUpdater.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.MessageFormat/.idea/vcs.xml b/src/.idea/.idea.MessageFormat/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/src/.idea/.idea.MessageFormat/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Benchmarks/Jeffijoe.MessageFormat.Benchmarks.csproj b/src/Jeffijoe.MessageFormat.Benchmarks/Jeffijoe.MessageFormat.Benchmarks.csproj new file mode 100644 index 0000000..6315f90 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Benchmarks/Jeffijoe.MessageFormat.Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + latest + enable + enable + True + MessageFormat.snk + + + + + + + + + + + diff --git a/src/Jeffijoe.MessageFormat.Benchmarks/MessageFormat.snk b/src/Jeffijoe.MessageFormat.Benchmarks/MessageFormat.snk new file mode 100644 index 0000000..ba4de3e Binary files /dev/null and b/src/Jeffijoe.MessageFormat.Benchmarks/MessageFormat.snk differ diff --git a/src/Jeffijoe.MessageFormat.Benchmarks/MessageFormatterBenchmarks.cs b/src/Jeffijoe.MessageFormat.Benchmarks/MessageFormatterBenchmarks.cs new file mode 100644 index 0000000..e7cdf05 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Benchmarks/MessageFormatterBenchmarks.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using Jeffijoe.MessageFormat; + +namespace Jeffijoe.MessageFormat.Benchmarks; + +[MemoryDiagnoser] +public class MessageFormatterBenchmarks +{ + private MessageFormatter _formatter = null!; + + private readonly Dictionary _simpleArgs = new() { ["name"] = "World" }; + + private readonly Dictionary _pluralSimpleArgs = new() { ["count"] = 5 }; + + private readonly Dictionary _selectSimpleArgs = new() { ["gender"] = "male" }; + + private readonly Dictionary _pluralOffsetArgs = new() { ["count"] = 3 }; + + private readonly Dictionary _nested2Args = new() { ["gender"] = "female", ["count"] = 1 }; + + private readonly Dictionary _nested3Args = new() + { + ["gender"] = "male", + ["count"] = 2, + ["total"] = 10 + }; + + [GlobalSetup] + public void Setup() + { + _formatter = new MessageFormatter(); + } + + [Benchmark] + public string SimpleSubstitution() + { + return _formatter.FormatMessage("{name}", _simpleArgs); + } + + [Benchmark] + public string PluralSimple() + { + return _formatter.FormatMessage( + "{count, plural, one {1 thing} other {# things}}", + _pluralSimpleArgs); + } + + [Benchmark] + public string SelectSimple() + { + return _formatter.FormatMessage( + "{gender, select, male {He} female {She} other {They}}", + _selectSimpleArgs); + } + + [Benchmark] + public string PluralWithOffset() + { + return _formatter.FormatMessage( + "{count, plural, offset:1 =0 {Nobody} one {You and one other} other {You and # others}}", + _pluralOffsetArgs); + } + + [Benchmark] + public string Nested2Levels() + { + return _formatter.FormatMessage( + "{gender, select, male {{count, plural, one {He has 1 item} other {He has # items}}} female {{count, plural, one {She has 1 item} other {She has # items}}} other {{count, plural, one {They have 1 item} other {They have # items}}}}", + _nested2Args); + } + + [Benchmark] + public string Nested3Levels() + { + return _formatter.FormatMessage( + "{gender, select, male {{count, plural, one {He has 1 of {total} items} other {He has # of {total} items}}} female {{count, plural, one {She has 1 of {total} items} other {She has # of {total} items}}} other {{count, plural, one {They have 1 of {total} items} other {They have # of {total} items}}}}", + _nested3Args); + } + + [Params(1, 2, 4, 8)] + public int ThreadCount { get; set; } + + [Benchmark] + public void MultiThreadFormatMessage() + { + var args = new Dictionary { ["count"] = 5 }; + var pattern = "{count, plural, one {1 thing} other {# things}}"; + + var tasks = new Task[ThreadCount]; + for (var t = 0; t < ThreadCount; t++) + { + tasks[t] = Task.Run(() => + { + for (var i = 0; i < 1000; i++) + { + _formatter.FormatMessage(pattern, args); + } + }); + } + + Task.WaitAll(tasks); + } +} diff --git a/src/Jeffijoe.MessageFormat.Benchmarks/PoolBenchmarks.cs b/src/Jeffijoe.MessageFormat.Benchmarks/PoolBenchmarks.cs new file mode 100644 index 0000000..9948aa8 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Benchmarks/PoolBenchmarks.cs @@ -0,0 +1,45 @@ +using System.Text; +using BenchmarkDotNet.Attributes; +using Jeffijoe.MessageFormat; + +namespace Jeffijoe.MessageFormat.Benchmarks; + +[MemoryDiagnoser] +public class PoolBenchmarks +{ + private const int OperationsPerThread = 1000; + + [Benchmark] + public void SingleThreadGetReturn() + { + for (var i = 0; i < OperationsPerThread; i++) + { + var sb = StringBuilderPool.Get(); + sb.Append("test"); + StringBuilderPool.Return(sb); + } + } + + [Params(1, 2, 4, 8)] + public int ThreadCount { get; set; } + + [Benchmark] + public void MultiThreadGetReturn() + { + var tasks = new Task[ThreadCount]; + for (var t = 0; t < ThreadCount; t++) + { + tasks[t] = Task.Run(() => + { + for (var i = 0; i < OperationsPerThread; i++) + { + var sb = StringBuilderPool.Get(); + sb.Append("test"); + StringBuilderPool.Return(sb); + } + }); + } + + Task.WaitAll(tasks); + } +} diff --git a/src/Jeffijoe.MessageFormat.Benchmarks/Program.cs b/src/Jeffijoe.MessageFormat.Benchmarks/Program.cs new file mode 100644 index 0000000..c9a0467 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj b/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj new file mode 100644 index 0000000..241670f --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj @@ -0,0 +1,30 @@ + + + + True + ../Jeffijoe.MessageFormat/MessageFormat.snk + default + enable + netstandard2.0 + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs new file mode 100644 index 0000000..05b0ae6 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +/// +/// Represents the 'condition' part of the LDML grammar. +/// +/// +/// Given the following 'pluralRule' tag: +/// <pluralRule count="one">i = 1 and v = 0 @integer 1</pluralRule> +/// +/// A Condition instance would represent 'i = 1 and v = 0' as a single . +/// +/// +/// The grammar defines a condition as a union of 'and_conditions', which we model as a +/// list of that each internally tracks . +/// +[DebuggerDisplay("{{RuleDescription}}")] +public class Condition +{ + public Condition(string count, string ruleDescription, IReadOnlyList orConditions) + { + Count = count; + RuleDescription = ruleDescription; + OrConditions = orConditions; + } + + /// + /// The plural form this condition or rule defines, e.g., "one", "two", "few", "many", "other". + /// + public string Count { get; } + + /// + /// The original text of this rule, e.g., "i = 1 and v = 0 @integer 1". + /// + /// + /// Note - this includes the sample text ('@integer 1') which gets stripped out + /// when parsing the rule's conditional logic. + /// + public string RuleDescription { get; } + + /// + /// Parsed representation of . + /// + public IReadOnlyList OrConditions { get; } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/ILeftOperand.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/ILeftOperand.cs new file mode 100644 index 0000000..401de53 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/ILeftOperand.cs @@ -0,0 +1,5 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public interface ILeftOperand +{ +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/IRightOperand.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/IRightOperand.cs new file mode 100644 index 0000000..206fa66 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/IRightOperand.cs @@ -0,0 +1,5 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public interface IRightOperand +{ +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/LeftOperand.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/LeftOperand.cs new file mode 100644 index 0000000..f756372 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/LeftOperand.cs @@ -0,0 +1,26 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public class ModuloOperand : ILeftOperand +{ + public ModuloOperand(OperandSymbol operandSymbol, int modValue) + { + Operand = operandSymbol; + ModValue = modValue; + } + + public OperandSymbol Operand { get; } + public int ModValue { get; } + + public override bool Equals(object? obj) + { + if (obj is ModuloOperand op) + return op.Operand == Operand && op.ModValue == ModValue; + + return this == obj; + } + + public override int GetHashCode() + { + return Operand.GetHashCode() + ModValue.GetHashCode(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/NumberOperand.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/NumberOperand.cs new file mode 100644 index 0000000..0b9fe2f --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/NumberOperand.cs @@ -0,0 +1,24 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public class NumberOperand : IRightOperand +{ + public NumberOperand(int number) + { + Number = number; + } + + public int Number { get; } + + public override bool Equals(object? obj) + { + if (obj is NumberOperand n) + return n.Number == Number; + + return this == obj; + } + + public override int GetHashCode() + { + return Number.GetHashCode(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/OperandSymbol.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/OperandSymbol.cs new file mode 100644 index 0000000..c07856b --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/OperandSymbol.cs @@ -0,0 +1,44 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public enum OperandSymbol +{ + /// + /// n - absolute value of the source number. + /// + AbsoluteValue, + + /// + /// i - integer digits of n. + /// + IntegerDigits, + + /// + /// v - number of visible fraction digits in n, with trailing zeros. + /// + VisibleFractionDigitNumber, + + /// + /// w - number of visible fraction digits in n, without trailing zeros. + /// + VisibleFractionDigitNumberWithoutTrailingZeroes, + + /// + /// f - number of visible fraction digits in n, with trailing zeros. + /// + VisibleFractionDigits, + + /// + /// t - visible fraction digits in n, without trailing zeros. + /// + VisibleFractionDigitsWithoutTrailingZeroes, + + /// + /// c - compact decimal exponent value: exponent of the power of 10 used in compact decimal formatting. + /// + ExponentC, + + /// + /// e - currently, synonym for ‘c’. however, may be redefined in the future. + /// + ExponentE, +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Operation.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Operation.cs new file mode 100644 index 0000000..4c00379 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Operation.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public class Operation +{ + public Operation(ILeftOperand operandLeft, Relation relation, IReadOnlyList operandRight) + { + OperandLeft = operandLeft; + Relation = relation; + OperandRight = operandRight; + } + + public ILeftOperand OperandLeft { get; } + + public Relation Relation { get; } + + public IReadOnlyList OperandRight { get; } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/OrCondition.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/OrCondition.cs new file mode 100644 index 0000000..379e08d --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/OrCondition.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public class OrCondition +{ + public OrCondition(IReadOnlyList andConditions) + { + AndConditions = andConditions; + } + + public IReadOnlyList AndConditions { get; } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs new file mode 100644 index 0000000..c37efeb --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +/// +/// Corresponds to a pluralRules tag in CLDR XML (not to be confused with pluralRule). +/// Each instance of this class represents multiple individual rules for a set of locales. +/// +/// +/// <pluralRules locales="ast de en et fi fy gl ia ie io ji lij nl sc sv sw ur yi"> +/// <pluralRule count = "one"> i = 1 and v = 0 @integer 1</pluralRule> +/// ... +/// </pluralRules> +/// +public class PluralRule +{ + public PluralRule(string[] locales, IReadOnlyList conditions) + { + Locales = locales; + Conditions = conditions; + } + + public string[] Locales { get; } + + public IReadOnlyList Conditions { get; } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/RangeOperand.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/RangeOperand.cs new file mode 100644 index 0000000..48c5ca5 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/RangeOperand.cs @@ -0,0 +1,26 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public class RangeOperand : IRightOperand +{ + public RangeOperand(int start, int end) + { + Start = start; + End = end; + } + + public int Start { get; } + public int End { get; } + + public override bool Equals(object? obj) + { + if (obj is RangeOperand n) + return n.Start == Start && n.End == End; + + return this == obj; + } + + public override int GetHashCode() + { + return Start.GetHashCode() + End.GetHashCode(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Relation.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Relation.cs new file mode 100644 index 0000000..435501c --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Relation.cs @@ -0,0 +1,6 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public enum Relation +{ + Equals, NotEquals +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/VariableOperand.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/VariableOperand.cs new file mode 100644 index 0000000..c6a883f --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/VariableOperand.cs @@ -0,0 +1,24 @@ +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +public class VariableOperand : ILeftOperand +{ + public VariableOperand(OperandSymbol operand) + { + Operand = operand; + } + + public OperandSymbol Operand { get; } + + public override bool Equals(object? obj) + { + if (obj is VariableOperand op) + return op.Operand == Operand; + + return this == obj; + } + + public override int GetHashCode() + { + return Operand.GetHashCode(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/InvalidCharacterException.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/InvalidCharacterException.cs new file mode 100644 index 0000000..396efb0 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/InvalidCharacterException.cs @@ -0,0 +1,10 @@ +using System; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; + +public class InvalidCharacterException : FormatException +{ + public InvalidCharacterException(char character) : base($"Invalid format, do not recognise character '{character}'") + { + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs new file mode 100644 index 0000000..1656357 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Xml; +using System.Linq; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; + +public class PluralParser +{ + private readonly XmlDocument _rulesDocument; + private readonly HashSet _excludedLocales; + + public PluralParser(XmlDocument rulesDocument, string[] excludedLocales) + { + _rulesDocument = rulesDocument; + _excludedLocales = new HashSet(excludedLocales); + } + + /// + /// Parses the represented XML document into a new , and returns it. + /// + /// A containing the parsed plural rules of a single type. + public PluralRuleSet Parse() + { + var index = new PluralRuleSet(); + ParseInto(index); + return index; + } + + /// + /// Parses the represented XML document and merges the rules into the given . + /// + /// + /// If the CLDR XML is missing expected attributes. + public void ParseInto(PluralRuleSet ruleIndex) + { + var root = _rulesDocument.DocumentElement!; + + foreach(XmlNode dataElement in root.ChildNodes) + { + if (dataElement.Name != "plurals") + { + continue; + } + + var typeAttr = dataElement.Attributes["type"]; + if (!typeAttr.Specified) + { + throw new ArgumentException("CLDR ruleset document is unexpectedly missing 'type' attribute on 'plurals' element."); + } + + string pluralType = typeAttr.Value; + + foreach (XmlNode rule in dataElement.ChildNodes) + { + if(rule.Name == "pluralRules") + { + var parsed = ParseSingleRule(rule); + if (parsed != null) + { + ruleIndex.Add(pluralType, parsed); + } + } + } + } + } + + private PluralRule? ParseSingleRule(XmlNode rule) + { + var locales = rule.Attributes!["locales"]!.Value.Split(' '); + + if (locales.All(l => _excludedLocales.Contains(l))) + { + return null; + } + + var conditions = new List(); + foreach (XmlNode condition in rule.ChildNodes) + { + if (condition.Name == "pluralRule") + { + var count = condition.Attributes!["count"]!.Value; + + // Ignore other, because other is basically everything else except for the conditions present + if (count == "other") + continue; + + var ruleContent = condition.InnerText; + + var ruleParser = new RuleParser(ruleContent); + var orConditions = ruleParser.ParseRuleContent(); + + conditions.Add(new Condition(count, ruleContent, orConditions)); + } + } + + return new PluralRule(locales, conditions); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs new file mode 100644 index 0000000..16eacd2 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs @@ -0,0 +1,133 @@ +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +using System; +using System.Collections.Generic; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; + +/// +/// Represents multiple fully parsed documents of instances, each with a given type (i.e., 'cardinal' vs 'ordinals'). +/// +public class PluralRuleSet +{ + /// + /// Special CLDR locale ID to use as the default for inheritance. All locales can chain to this + /// during lookups. + /// + public const string RootLocale = "root"; + + /// + /// CLDR plural type attribute for the counting number ruleset. + /// Used to translate strings that contain a count, e.g., to pluralize nouns. + /// + public const string CardinalType = "cardinal"; + + /// + /// CLDR plural type attribute for the ordered number ruleset. + /// Used to translate strings containing ordinal numbers, e.g., "1st", "2nd", "3rd". + /// + public const string OrdinalType = "ordinal"; + + // Backing fields for the public properties below. + private readonly List _allRules = []; + private readonly Dictionary _indicesByLocale = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the unique conditions that have been indexed. Can be used to generate unique helper functions + /// to match specific rules based on an input number. + /// + public IReadOnlyList UniqueRules => this._allRules; + + /// + /// Maps normalized CLDR locale IDs to indices within + /// for their cardinal and ordinal rules, if defined. + /// + public IReadOnlyDictionary RuleIndicesByLocale => this._indicesByLocale; + + /// + /// Adds the given rule to our indices under the given plural type. + /// + /// e.g., 'cardinal' or 'ordinal'. + /// The parsed rule. + /// Thrown when a nonstandard plural type is provided. + public void Add(string pluralType, PluralRule rule) + { + this._allRules.Add(rule); + int newRuleIndex = this._allRules.Count - 1; + + int? cardinalIndex = null; + int? ordinalIndex = null; + if (pluralType == CardinalType) + { + cardinalIndex = newRuleIndex; + } + else if (pluralType == OrdinalType) + { + ordinalIndex = newRuleIndex; + } + else + { + throw new ArgumentOutOfRangeException(nameof(pluralType), pluralType, "Unexpected plural type"); + } + + // Loop over each locale for this rule and update our indices with the new value. + // If we've seen it before (for a different plural type), we'll update it in-place. + foreach (var locale in rule.Locales) + { + var normalized = this.NormalizeCldrLocale(locale); + + PluralRuleIndices newIndices; + if (this._indicesByLocale.TryGetValue(normalized, out var existingIndices)) + { + // Merge any previous indices we've observed for this locale + newIndices = existingIndices with + { + CardinalRuleIndex = cardinalIndex ?? existingIndices.CardinalRuleIndex, + OrdinalRuleIndex = ordinalIndex ?? existingIndices.OrdinalRuleIndex + }; + } + else + { + newIndices = new PluralRuleIndices( + CardinalRuleIndex: cardinalIndex, + OrdinalRuleIndex: ordinalIndex + ); + + } + + this._indicesByLocale[normalized] = newIndices; + if (normalized != locale) + { + this._indicesByLocale[locale] = newIndices; + } + } + } + + /// + /// Converts a CLDR locale ID to a normalized form for indexing. + /// + /// See the LDML spec + /// for an explanation of the forms that Unicode locale IDs can take. + /// + /// Notably, CLDR locale IDs use underscores as separators, while BCP 47 (which is the primary form + /// we expect as inputs at runtime) use dashes. + /// + /// + /// The return string is intended to be used for case-insensitive runtime lookup of input locales, + /// but the string itself is not strictly BCP 47 or CLDR compliant. For example, the CLDR 'root' + /// language is passed through instead of being remapped to 'und'. + /// + private string NormalizeCldrLocale(string cldrLocaleId) + { + return cldrLocaleId.Replace('_', '-'); + } + + /// + /// Helper type to represent the pluralization rules for a given locale, which may include both + /// cardinal and ordinal rules, or just one of the two. + /// + /// + /// For example, in CLDR 48.1, "pt_PT" has a defined plural rule but is expected to chain to "pt" + /// for ordinal lookup. + /// + public record PluralRuleIndices(int? CardinalRuleIndex, int? OrdinalRuleIndex); +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/RuleParser.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/RuleParser.cs new file mode 100644 index 0000000..722ec07 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/RuleParser.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; + +public class RuleParser +{ + private readonly string _ruleText; + private int _position; + + public RuleParser(string ruleText) + { + _ruleText = ruleText; + } + + public IReadOnlyList ParseRuleContent() + { + var conditions = new List(); + + while (!IsEnd) + { + if (PeekCurrentChar == '@') + { + return conditions; + } + + var condition = ParseOrCondition(); + conditions.Add(condition); + + AdvanceWhitespace(); + + if (IsEnd) + { + return conditions; + } + + var character = ConsumeChar(); + + // This is where the samples start, we don't care about any of those. + if (character == '@') + { + return conditions; + } + + // We expect the next token to be "or" + var characterNext = ConsumeChar(); + if (character == 'o' && characterNext == 'r') + { + continue; + } + + throw new InvalidCharacterException(character); + } + + return conditions; + } + + private static readonly char NullCharacter = '\0'; + + private char PeekCurrentChar => + _position < _ruleText.Length + ? _ruleText[_position] + : NullCharacter; + + private char PeekNextChar => + _position + 1 < _ruleText.Length + ? _ruleText[_position + 1] + : NullCharacter; + + private char PeekAt(int delta) + { + if (_position + delta >= _ruleText.Length) + return NullCharacter; + + return _ruleText[_position + delta]; + } + + private ReadOnlySpan ConsumeCharacters(int count) + { + if (_position + count > _ruleText.Length) + { + var characters = _ruleText.AsSpan(_position, _ruleText.Length - _position); + _position = _ruleText.Length; + return characters; + } + else + { + var characters = _ruleText.AsSpan(_position, count); + _position += count; + return characters; + } + } + + private char ConsumeChar() + { + if (IsEnd) + return NullCharacter; + + var character = PeekCurrentChar; + _position++; + return character; + } + + private bool IsEnd => _position >= _ruleText.Length; + + private void AdvanceWhitespace() + { + while (!IsEnd && char.IsWhiteSpace(PeekCurrentChar)) + { + ConsumeChar(); + } + } + + private ILeftOperand ParseLeftOperand() + { + var operandSymbol = ConsumeChar() switch + { + 'n' => OperandSymbol.AbsoluteValue, + 'i' => OperandSymbol.IntegerDigits, + 'v' => OperandSymbol.VisibleFractionDigitNumber, + 'w' => OperandSymbol.VisibleFractionDigitNumberWithoutTrailingZeroes, + 'f' => OperandSymbol.VisibleFractionDigits, + 't' => OperandSymbol.VisibleFractionDigitsWithoutTrailingZeroes, + 'c' => OperandSymbol.ExponentC, + 'e' => OperandSymbol.ExponentE, + var otherCharacter => throw new InvalidCharacterException(otherCharacter) + }; + + AdvanceWhitespace(); + + if(PeekCurrentChar == '%') + { + ConsumeChar(); + AdvanceWhitespace(); + + var number = ParseNumber(); + + return new ModuloOperand(operandSymbol, number); + } + + return new VariableOperand(operandSymbol); + } + + private Operation ParseAndCondition() + { + AdvanceWhitespace(); + var leftOperand = ParseLeftOperand(); + + + AdvanceWhitespace(); + var firstRelationCharacter = ConsumeChar(); + var relation = firstRelationCharacter switch + { + '=' => Relation.Equals, + '!' when ConsumeChar() == '=' + => Relation.NotEquals, + var otherCharacter => throw new InvalidCharacterException(otherCharacter) + }; + + AdvanceWhitespace(); + var rightOperand = ParseRightOperand(); + return new Operation(leftOperand, relation, rightOperand); + } + + private IReadOnlyList ParseRightOperand() + { + var numbers = new List(); + + while (!IsEnd) + { + AdvanceWhitespace(); + + var number = ParseNumber(); + if (PeekCurrentChar == '.') + { + if (PeekNextChar == '.') + { + ConsumeCharacters(2); + AdvanceWhitespace(); + + var nextNumber = ParseNumber(); + numbers.Add(new RangeOperand(number, nextNumber)); + } + else + { + throw new InvalidCharacterException(PeekCurrentChar); + } + } + else + { + numbers.Add(new NumberOperand(number)); + } + + if (PeekCurrentChar == ',') + { + ConsumeChar(); + AdvanceWhitespace(); + continue; + } + + break; + } + + return numbers; + } + + private OrCondition ParseOrCondition() + { + var andWordSpan = "and".AsSpan(); + + var andConditions = new List(); + while (!IsEnd) + { + var operation = ParseAndCondition(); + andConditions.Add(operation); + + AdvanceWhitespace(); + + if (PeekCurrentChar == 'a') + { + var andWord = ConsumeCharacters(3); + + + if (andWord.SequenceEqual(andWordSpan)) + { + continue; + } + + throw new InvalidCharacterException(andWord[0]); + } + + return new OrCondition(andConditions); + } + + return new OrCondition(andConditions); + } + + private int ParseNumber() + { + int numbersCount = 0; + while (!IsEnd && char.IsNumber(PeekAt(numbersCount))) + { + numbersCount++; + } + + var numberSpan = ConsumeCharacters(numbersCount); + + var number = int.Parse(numberSpan.ToString()); + + return number; + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs new file mode 100644 index 0000000..f12b098 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs @@ -0,0 +1,48 @@ +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; + +using Microsoft.CodeAnalysis; + +using System.IO; +using System.Xml; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural; + +[Generator] +public class PluralLanguagesGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static spc => + { + // Not currently excluding any locales. + var rules = GetRules(excludedLocales: []); + var generator = new PluralRulesMetadataGenerator(rules); + var sourceCode = generator.GenerateClass(); + + spc.AddSource("PluralRulesMetadata.Generated.cs", sourceCode); + }); + } + + private static PluralRuleSet GetRules(string[] excludedLocales) + { + PluralRuleSet ruleIndex = new(); + foreach (var ruleset in new[] { "plurals.xml", "ordinals.xml" }) + { + using var rulesStream = GetRulesContentStream(ruleset); + var xml = new XmlDocument(); + xml.Load(rulesStream); + + var parser = new PluralParser(xml, excludedLocales); + parser.ParseInto(ruleIndex); + } + + return ruleIndex; + } + + private static Stream GetRulesContentStream(string cldrFileName) + { + return typeof(PluralLanguagesGenerator).Assembly + .GetManifestResourceStream($"Jeffijoe.MessageFormat.MetadataGenerator.data.{cldrFileName}")!; + } +} diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs new file mode 100644 index 0000000..bc3a132 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs @@ -0,0 +1,145 @@ +using System.Text; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; + +public class PluralRulesMetadataGenerator +{ + private readonly PluralRuleSet _rules; + private readonly StringBuilder _sb; + private int _indent; + + public PluralRulesMetadataGenerator(PluralRuleSet rules) + { + _rules = rules; + _sb = new StringBuilder(); + } + + public string GenerateClass() + { + WriteLine("#nullable enable"); + WriteLine("using System;"); + WriteLine("using System.Collections.Generic;"); + WriteLine("using System.Diagnostics.CodeAnalysis;"); + + WriteLine("namespace Jeffijoe.MessageFormat.Formatting.Formatters"); + WriteLine("{"); + AddIndent(); + + WriteLine("internal static partial class PluralRulesMetadata"); + WriteLine("{"); + AddIndent(); + + // Export a constant for the normalized root locale to match the logic we're using internally. + // This way the rest of the lib's locale chaining can continue to work if we swap out + // normalization internally. + WriteLine($"public static readonly string RootLocale = \"{PluralRuleSet.RootLocale}\";"); + + // Generate a method for each unique rule, by index, that chooses the plural form + // for a given input source number (the PluralContext) according to that rule. + var uniqueRules = _rules.UniqueRules; + for (var ruleIdx = 0; ruleIdx < uniqueRules.Count; ruleIdx++) + { + var rule = uniqueRules[ruleIdx]; + var ruleGenerator = new RuleGenerator(rule); + + WriteLine($"private static string Rule{ruleIdx}(PluralContext context)"); + WriteLine("{"); + AddIndent(); + + ruleGenerator.WriteTo(_sb, _indent); + + DecreaseIndent(); + WriteLine("}"); + WriteLine(string.Empty); + } + + // Generate a static lookup dictionary of locale (case-insensitive) to LocalePluralizers for that locale. + // e.g., + // en -> { + // Cardinal = Rule0, + // Ordinal = Rule1, + // }, + // [etc for other locales, with some null values for unmapped locales] + WriteLine("private static readonly Dictionary Pluralizers = new(StringComparer.OrdinalIgnoreCase)"); + WriteLine("{"); + AddIndent(); + + foreach (var kvp in _rules.RuleIndicesByLocale) + { + string locale = kvp.Key; + + // When index is defined, we want "Rule#" as a reference to the delegate generated above; + // otherwise we want null. + int? cardinalIdx = kvp.Value.CardinalRuleIndex; + int? ordinalIdx = kvp.Value.OrdinalRuleIndex; + string cardinalValue = cardinalIdx is not null ? $"Rule{cardinalIdx}" : "null"; + string ordinalValue = ordinalIdx is not null ? $"Rule{ordinalIdx}" : "null"; + + WriteLine($"{{\"{locale}\", new LocalePluralizers(Cardinal: {cardinalValue}, Ordinal: {ordinalValue})}},"); + } + + DecreaseIndent(); + WriteLine("};"); + WriteLine(string.Empty); + + // Finally generate our public API to the rest of the library, that takes a locale and pluralType + // and tries to retrieve an appropriate localizer to map an input source number to the form for the request. + WriteLine("public static partial bool TryGetCardinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer)"); + WriteLine("{"); + AddIndent(); + + WriteLine("if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale))"); + WriteLine("{"); + AddIndent(); + WriteLine("contextPluralizer = null;"); + WriteLine("return false;"); + DecreaseIndent(); + WriteLine("}"); + WriteLine("contextPluralizer = pluralizersForLocale.Cardinal;"); + WriteLine("return contextPluralizer != null;"); + + DecreaseIndent(); + WriteLine("}"); + WriteLine(string.Empty); + + // Repeat the above for ordinal rules. + WriteLine("public static partial bool TryGetOrdinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer)"); + WriteLine("{"); + AddIndent(); + + WriteLine("if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale))"); + WriteLine("{"); + AddIndent(); + WriteLine("contextPluralizer = null;"); + WriteLine("return false;"); + DecreaseIndent(); + WriteLine("}"); + WriteLine("contextPluralizer = pluralizersForLocale.Ordinal;"); + WriteLine("return contextPluralizer != null;"); + + DecreaseIndent(); + WriteLine("}"); + + // Generate the helper record and then clean up. + WriteLine(string.Empty); + WriteLine("private record LocalePluralizers(ContextPluralizer? Cardinal, ContextPluralizer? Ordinal);"); + + DecreaseIndent(); + WriteLine("}"); + + DecreaseIndent(); + WriteLine("}"); + + return _sb.ToString(); + } + + private void AddIndent() => _indent += 4; + private void DecreaseIndent() => _indent -= 4; + + private void WriteLine(string line) + { + _sb.Append(' ', _indent); + _sb.AppendLine(line); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/RuleGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/RuleGenerator.cs new file mode 100644 index 0000000..88dcefa --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/RuleGenerator.cs @@ -0,0 +1,134 @@ +using System; +using System.Text; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; + +public class RuleGenerator +{ + private readonly PluralRule _rule; + private int _innerIndent; + + public RuleGenerator(PluralRule rule) + { + _rule = rule; + _innerIndent = 0; + } + + public void WriteTo(StringBuilder builder, int indent) + { + foreach(var condition in _rule.Conditions) + { + WriteNext(condition, builder, indent); + } + + WriteOther(builder, indent); + } + + private void WriteOther(StringBuilder builder, int indent) + { + WriteLine(builder, "return \"other\";", indent); + } + + private void WriteNext(Condition condition, StringBuilder builder, int indent) + { + if(condition.OrConditions.Count > 0) + { + builder.Append(' ', _innerIndent + indent); + builder.Append("if ("); + + for (int orIdx = 0; orIdx < condition.OrConditions.Count; orIdx++) + { + OrCondition orCondition = condition.OrConditions[orIdx]; + var orIsLast = orIdx == condition.OrConditions.Count - 1; + + WriteOrCondition(builder, orCondition); + + if (!orIsLast) + { + builder.Append(" || "); + } + } + + builder.AppendLine(")"); + + _innerIndent += 4; + WriteLine(builder, $"return \"{condition.Count}\";", indent); + _innerIndent -= 4; + + WriteLine(builder, string.Empty, indent); + } + else + { + throw new InvalidOperationException("Expected to have at least one or condition, but got none"); + } + } + + private void WriteOrCondition(StringBuilder builder, OrCondition orCondition) + { + for (int andIdx = 0; andIdx < orCondition.AndConditions.Count; andIdx++) + { + var andIsLast = andIdx == orCondition.AndConditions.Count - 1; + Operation andCondition = orCondition.AndConditions[andIdx]; + builder.Append('('); + + for (int innerOrIdx = 0; innerOrIdx < andCondition.OperandRight.Count; innerOrIdx++) + { + var isLast = innerOrIdx == andCondition.OperandRight.Count - 1; + + var leftVariable = andCondition.OperandLeft switch + { + VariableOperand op => $"context.{OperandToVariable(op.Operand)}", + ModuloOperand op => $"context.{OperandToVariable(op.Operand)} % {op.ModValue}", + var otherOp => throw new InvalidOperationException($"Unknown operation {otherOp.GetType()}") + }; + + var line = andCondition.OperandRight[innerOrIdx] switch + { + RangeOperand range => andCondition.Relation == Relation.Equals + ? $"{leftVariable} >= {range.Start} && {leftVariable} <= {range.End}" + : $"({leftVariable} < {range.Start} || {leftVariable} > {range.End})", + NumberOperand number => andCondition.Relation == Relation.Equals + ? $"{leftVariable} == {number.Number}" + : $"{leftVariable} != {number.Number}", + var otherOperand => throw new InvalidOperationException($"Unknown right operand {otherOperand.GetType()}") + }; + + builder.Append(line); + + if (!isLast) + { + builder.Append(andCondition.Relation == Relation.Equals ? " || " : " && "); + } + } + builder.Append(')'); + + if (!andIsLast) + { + builder.Append(" && "); + } + } + } + + private char OperandToVariable(OperandSymbol operand) + { + return operand switch + { + OperandSymbol.AbsoluteValue => 'N', + OperandSymbol.IntegerDigits => 'I', + OperandSymbol.VisibleFractionDigitNumber => 'V', + OperandSymbol.VisibleFractionDigitNumberWithoutTrailingZeroes => 'W', + OperandSymbol.VisibleFractionDigits => 'F', + OperandSymbol.VisibleFractionDigitsWithoutTrailingZeroes => 'T', + OperandSymbol.ExponentC => 'C', + OperandSymbol.ExponentE => 'E', + _ => throw new InvalidOperationException($"Unknown variable {operand}") + }; + } + + private void WriteLine(StringBuilder builder, string value, int indent) + { + builder.Append(' ', indent + _innerIndent); + builder.AppendLine(value); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md new file mode 100644 index 0000000..75ddf78 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md @@ -0,0 +1,3 @@ +CLDR supplemental data files obtained from https://cldr.unicode.org/downloads/cldr-48 + +CLDR v48.1 was released 2025-01-08; refer to https://cldr.unicode.org/index/downloads \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml new file mode 100644 index 0000000..c8ea54b --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + @integer 0~15, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 1,2 and n % 100 != 11,12 @integer 1, 2, 21, 22, 31, 32, 41, 42, 51, 52, 61, 62, 71, 72, 81, 82, 101, 1001, … + @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 1 @integer 1 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,5 @integer 1, 5 + @integer 0, 2~4, 6~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 1..4 @integer 1~4 + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 2,3 and n % 100 != 12,13 @integer 2, 3, 22, 23, 32, 33, 42, 43, 52, 53, 62, 63, 72, 73, 82, 83, 102, 1002, … + @integer 0, 1, 4~17, 100, 1000, 10000, 100000, 1000000, … + + + n % 10 = 3 and n % 100 != 13 @integer 3, 23, 33, 43, 53, 63, 73, 83, 103, 1003, … + @integer 0~2, 4~16, 100, 1000, 10000, 100000, 1000000, … + + + n % 10 = 6,9 or n = 10 @integer 6, 9, 10, 16, 19, 26, 29, 36, 39, 106, 1006, … + @integer 0~5, 7, 8, 11~15, 17, 18, 20, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 6 or n % 10 = 9 or n % 10 = 0 and n != 0 @integer 6, 9, 10, 16, 19, 20, 26, 29, 30, 36, 39, 40, 100, 1000, 10000, 100000, 1000000, … + @integer 0~5, 7, 8, 11~15, 17, 18, 21, 101, 1001, … + + + n = 11,8,80,800 @integer 8, 11, 80, 800 + @integer 0~7, 9, 10, 12~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 11,8,80..89,800..899 @integer 8, 11, 80~89, 800~803 + @integer 0~7, 9, 10, 12~17, 100, 1000, 10000, 100000, 1000000, … + + + + + + i = 1 @integer 1 + i = 0 or i % 100 = 2..20,40,60,80 @integer 0, 2~16, 102, 1002, … + @integer 21~36, 100, 1000, 10000, 100000, 1000000, … + + + n = 1 @integer 1 + n % 10 = 4 and n % 100 != 14 @integer 4, 24, 34, 44, 54, 64, 74, 84, 104, 1004, … + @integer 0, 2, 3, 5~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 1..4 or n % 100 = 1..4,21..24,41..44,61..64,81..84 @integer 1~4, 21~24, 41~44, 61~64, 101, 1001, … + n = 5 or n % 100 = 5 @integer 5, 105, 205, 305, 405, 505, 605, 705, 1005, … + @integer 0, 6~20, 100, 1000, 10000, 100000, 1000000, … + + + + + + i = 0 @integer 0 + i = 1 @integer 1 + i = 2,3,4,5,6 @integer 2~6 + @integer 7~22, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … + n % 10 = 2 and n % 100 != 12 @integer 2, 22, 32, 42, 52, 62, 72, 82, 102, 1002, … + n % 10 = 3 and n % 100 != 13 @integer 3, 23, 33, 43, 53, 63, 73, 83, 103, 1003, … + @integer 0, 4~18, 100, 1000, 10000, 100000, 1000000, … + + + n = 1 @integer 1 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,11 @integer 1, 11 + n = 2,12 @integer 2, 12 + n = 3,13 @integer 3, 13 + @integer 0, 4~10, 14~21, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,3 @integer 1, 3 + n = 2 @integer 2 + n = 4 @integer 4 + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + + + + i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … + i % 10 = 2 and i % 100 != 12 @integer 2, 22, 32, 42, 52, 62, 72, 82, 102, 1002, … + i % 10 = 7,8 and i % 100 != 17,18 @integer 7, 8, 27, 28, 37, 38, 47, 48, 57, 58, 67, 68, 77, 78, 87, 88, 107, 1007, … + @integer 0, 3~6, 9~19, 100, 1000, 10000, 100000, 1000000, … + + + + + + i % 10 = 1,2,5,7,8 or i % 100 = 20,50,70,80 @integer 1, 2, 5, 7, 8, 11, 12, 15, 17, 18, 20~22, 25, 101, 1001, … + i % 10 = 3,4 or i % 1000 = 100,200,300,400,500,600,700,800,900 @integer 3, 4, 13, 14, 23, 24, 33, 34, 43, 44, 53, 54, 63, 64, 73, 74, 100, 1003, … + i = 0 or i % 10 = 6 or i % 100 = 40,60,90 @integer 0, 6, 16, 26, 36, 40, 46, 56, 106, 1006, … + @integer 9, 10, 19, 29, 30, 39, 49, 59, 69, 79, 109, 1000, 10000, 100000, 1000000, … + + + + + + n = 1 @integer 1 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + n = 6 @integer 6 + @integer 0, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,5,7,8,9,10 @integer 1, 5, 7~10 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + n = 6 @integer 6 + @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,5,7..9 @integer 1, 5, 7~9 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + n = 6 @integer 6 + @integer 0, 10~24, 100, 1000, 10000, 100000, 1000000, … + + + + + + n = 0,7,8,9 @integer 0, 7~9 + n = 1 @integer 1 + n = 2 @integer 2 + n = 3,4 @integer 3, 4 + n = 5,6 @integer 5, 6 + @integer 10~25, 100, 1000, 10000, 100000, 1000000, … + + + diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml new file mode 100644 index 0000000..26cca25 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + i = 0,1 @integer 0, 1 @decimal 0.0~1.5 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + i = 1 and v = 0 @integer 1 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0..1 or n = 11..99 @integer 0, 1, 11~24 @decimal 0.0, 1.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0 + @integer 2~10, 100~106, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 or t != 0 and i = 0,1 @integer 1 @decimal 0.1~1.6 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0~3.4, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~0.9, 1.2~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~1.0, 1.2~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, … + + + + + + n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 1 and v = 0 or i = 0 and v != 0 @integer 1 @decimal 0.0~0.9, 0.00~0.05 + i = 2 and v = 0 @integer 2 + @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.0~2.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 + n = 2..10 @integer 2~10 @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00 + @integer 11~26, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~1.9, 2.1~2.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + i = 1 and v = 0 @integer 1 + v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 20~35, 100, 1000, 10000, 100000, 1000000, … + + + v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 0,1 @integer 0, 1 @decimal 0.0~1.5 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + i = 0..1 @integer 0, 1 @decimal 0.0~1.5 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + i = 1 and v = 0 @integer 1 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + + + + n = 1,11 @integer 1, 11 @decimal 1.0, 11.0, 1.00, 11.00, 1.000, 11.000, 1.0000 + n = 2,12 @integer 2, 12 @decimal 2.0, 12.0, 2.00, 12.00, 2.000, 12.000, 2.0000 + n = 3..10,13..19 @integer 3~10, 13~19 @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 3.00 + @integer 0, 20~34, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … + v = 0 and i % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … + v = 0 and i % 100 = 3..4 or v != 0 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, … + v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 1 and v = 0 @integer 1 + i = 2..4 and v = 0 @integer 2~4 + v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + i = 1 and v = 0 @integer 1 + v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … + v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n % 10 = 2..4 and n % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 2.0, 3.0, 4.0, 22.0, 23.0, 24.0, 32.0, 33.0, 102.0, 1002.0, … + n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + + + n % 10 = 1 and n % 100 != 11..19 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n % 10 = 2..9 and n % 100 != 11..19 @integer 2~9, 22~29, 102, 1002, … @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, … + f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … + v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … + v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n != 2 and n % 10 = 2..9 and n % 100 != 11..19 @integer 3~9, 22~29, 32, 102, 1002, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, … + f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, … + n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, … + n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 @integer 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, … @decimal 3.0, 4.0, 9.0, 23.0, 24.0, 29.0, 33.0, 34.0, 103.0, 1003.0, … + n != 0 and n % 1000000 = 0 @integer 1000000, … @decimal 1000000.0, 1000000.00, 1000000.000, 1000000.0000, … + @integer 0, 5~8, 10~20, 100, 1000, 10000, 100000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n = 0 or n % 100 = 3..10 @integer 0, 3~10, 103~109, 1003, … @decimal 0.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, … + n % 100 = 11..19 @integer 11~19, 111~117, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, … + @integer 20~35, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n = 3..6 @integer 3~6 @decimal 3.0, 4.0, 5.0, 6.0, 3.00, 4.00, 5.00, 6.00, 3.000, 4.000, 5.000, 6.000, 3.0000, 4.0000, 5.0000, 6.0000 + n = 7..10 @integer 7~10 @decimal 7.0, 8.0, 9.0, 10.0, 7.00, 8.00, 9.00, 10.00, 7.000, 8.000, 9.000, 10.000, 7.0000, 8.0000, 9.0000, 10.0000 + @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, … + v = 0 and i % 10 = 2 @integer 2, 12, 22, 32, 42, 52, 62, 72, 102, 1002, … + v = 0 and i % 100 = 0,20,40,60,80 @integer 0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000, … + v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 3~10, 13~19, 23, 103, 1003, … + + + + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000 @integer 2, 22, 42, 62, 82, 102, 122, 142, 1000, 10000, 100000, … @decimal 2.0, 22.0, 42.0, 62.0, 82.0, 102.0, 122.0, 142.0, 1000.0, 10000.0, 100000.0, … + n % 100 = 3,23,43,63,83 @integer 3, 23, 43, 63, 83, 103, 123, 143, 1003, … @decimal 3.0, 23.0, 43.0, 63.0, 83.0, 103.0, 123.0, 143.0, 1003.0, … + n != 1 and n % 100 = 1,21,41,61,81 @integer 21, 41, 61, 81, 101, 121, 141, 161, 1001, … @decimal 21.0, 41.0, 61.0, 81.0, 101.0, 121.0, 141.0, 161.0, 1001.0, … + @integer 4~19, 100, 1004, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.1, 1000000.0, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, … + n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, … + @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n = 3 @integer 3 @decimal 3.0, 3.00, 3.000, 3.0000 + n = 6 @integer 6 @decimal 6.0, 6.00, 6.000, 6.0000 + @integer 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/BaseFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/BaseFormatterTests.cs index e62c0dd..5b5e9b6 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/BaseFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/BaseFormatterTests.cs @@ -4,330 +4,369 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; -using System.Text; - using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Parsing; using Jeffijoe.MessageFormat.Tests.TestHelpers; - using Xunit; using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests.Formatting +namespace Jeffijoe.MessageFormat.Tests.Formatting; + +/// +/// The base formatter tests. +/// +public class BaseFormatterTests { + #region Fields + /// - /// The base formatter tests. + /// The output helper. /// - public class BaseFormatterTests - { - #region Fields + private readonly ITestOutputHelper outputHelper; - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; + #endregion - #endregion + #region Constructors and Destructors - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public BaseFormatterTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public BaseFormatterTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } - #endregion + #endregion - #region Public Properties + #region Public Properties - /// - /// Gets the parse arguments_tests. - /// - public static IEnumerable ParseArguments_tests + /// + /// Gets the parse arguments_tests. + /// + public static IEnumerable ParseArgumentsTests + { + get { - get - { - yield return - new object[] - { - "offset:1 test:1337 one {programmer} other{programmers}", - new[] { "offset", "test" }, - new[] { "1", "1337" }, - new[] { "one", "other" }, - new[] { "programmer", "programmers" } - }; - - yield return - new object[] - { - "offset:1 test:1337 one\\123 {programmer} other{programmers}", - new[] { "offset", "test" }, - new[] { "1", "1337" }, - new[] { "one\\123", "other" }, - new[] { "programmer", "programmers" } - }; - } + yield return + new object[] + { + "offset:1 test:1337 one {programmer} other{programmers}", + new[] { "offset", "test" }, + new[] { "1", "1337" }, + new[] { "one", "other" }, + new[] { "programmer", "programmers" } + }; + + yield return + new object[] + { + "offset:1 test:1337 one\\123 {programmer} other{programmers}", + new[] { "offset", "test" }, + new[] { "1", "1337" }, + new[] { "one\\123", "other" }, + new[] { "programmer", "programmers" } + }; } + } - /// - /// Gets the parse keyed blocks_tests. - /// - public static IEnumerable ParseKeyedBlocks_tests + /// + /// Gets the parse keyed blocks_tests. + /// + public static IEnumerable ParseKeyedBlocksTests + { + get { - get + yield return + new object?[] + { + null, + Array.Empty(), + Array.Empty() + }; + yield return + new object[] + { + "male {he} female {she}unknown{they}", + new[] { "male", "female", "unknown" }, + new[] { "he", "she", "they" } + }; + yield return + new object[] + { + "zero {} other {wee}", + new[] { "zero", "other" }, + new[] { string.Empty, "wee" } + }; + yield return + new object[] + { + "male {''he''}", + new[] { "male"}, + new[] { "''he''" } + }; + yield return new object[] { - yield return - new object[] - { - "male {he} female {she}unknown{they}", - new[] { "male", "female", "unknown" }, - new[] { "he", "she", "they" } - }; - yield return - new object[] - { - "zero {} other {wee}", - new[] { "zero", "other" }, - new[] { string.Empty, "wee" } - }; - yield return new object[] { @" + @" male {he} female {she} unknown {they} -", new[] { "male", "female", "unknown" }, new[] { "he", "she", "they" } }; - yield return new object[] { @" +", + new[] { "male", "female", "unknown" }, new[] { "he", "she", "they" } + }; + yield return new object[] + { + @" male {he} female {she{dawg}} unknown {they'{dawg}'} -", new[] { "male", "female", "unknown" }, new[] { "he", "she{dawg}", @"they'{dawg}'" } }; - } +", + new[] { "male", "female", "unknown" }, new[] { "he", "she{dawg}", @"they'{dawg}'" } + }; } + } - #endregion - - #region Public Methods and Operators - - /// - /// The parse arguments. - /// - /// - /// The args. - /// - /// - /// The extension keys. - /// - /// - /// The extension values. - /// - /// - /// The keys. - /// - /// - /// The blocks. - /// - [Theory] - [MemberData(nameof(ParseArguments_tests))] - public void ParseArguments( - string args, - string[] extensionKeys, - string[] extensionValues, - string[] keys, - string[] blocks) - { - var subject = new BaseFormatterImpl(); - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), null, null, args); - var actual = subject.ParseArguments(req); + #endregion - Assert.Equal(extensionKeys.Length, actual.Extensions.Count()); - Assert.Equal(keys.Length, actual.KeyedBlocks.Count()); + #region Public Methods and Operators - for (int i = 0; i < actual.Extensions.ToArray().Length; i++) - { - var extension = actual.Extensions.ToArray()[i]; - Assert.Equal(extensionKeys[i], extension.Extension); - Assert.Equal(extensionValues[i], extension.Value); - } + /// + /// The parse arguments. + /// + /// + /// The args. + /// + /// + /// The extension keys. + /// + /// + /// The extension values. + /// + /// + /// The keys. + /// + /// + /// The blocks. + /// + [Theory] + [MemberData(nameof(ParseArgumentsTests))] + public void ParseArguments( + string args, + string[] extensionKeys, + string[] extensionValues, + string[] keys, + string[] blocks) + { + var subject = new BaseFormatterImpl(); + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); + var actual = subject.ParseArguments(req); - for (int i = 0; i < actual.KeyedBlocks.ToArray().Length; i++) - { - var block = actual.KeyedBlocks.ToArray()[i]; - Assert.Equal(keys[i], block.Key); - Assert.Equal(blocks[i], block.BlockText); - } - } + Assert.Equal(extensionKeys.Length, actual.Extensions.Count()); + Assert.Equal(keys.Length, actual.KeyedBlocks.Count()); - /// - /// The parse arguments_invalid. - /// - /// - /// The args. - /// - [Theory] - [InlineData("hello {{dawg}")] - [InlineData("hello {dawg}}")] - [InlineData("hello '{dawg}")] - [InlineData("hello {dawg'}")] - [InlineData("hello {dawg} {sweet}")] - [InlineData("hello {dawg} test{sweet}}")] - [InlineData("hello '{{dawg'}} test{sweet}")] - public void ParseArguments_invalid(string args) + for (int i = 0; i < actual.Extensions.ToArray().Length; i++) { - var subject = new BaseFormatterImpl(); - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), null, null, args); - var ex = Assert.Throws(() => subject.ParseArguments(req)); - this.outputHelper.WriteLine(ex.Message); + var extension = actual.Extensions.ToArray()[i]; + Assert.Equal(extensionKeys[i], extension.Extension); + Assert.Equal(extensionValues[i], extension.Value); } - /// - /// The parse extensions. - /// - /// - /// The args. - /// - /// - /// The extension. - /// - /// - /// The value. - /// - /// - /// The expected index. - /// - [Theory] - [InlineData(" offset:3 boom", "offset", "3", 9)] - [InlineData("testie:dawg lel", "testie", "dawg", 11)] - public void ParseExtensions(string args, string extension, string value, int expectedIndex) + for (int i = 0; i < actual.KeyedBlocks.ToArray().Length; i++) { - var subject = new BaseFormatterImpl(); - int index; - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), null, null, args); - - // Warmup - subject.ParseExtensions(req, out index); + var block = actual.KeyedBlocks.ToArray()[i]; + Assert.Equal(keys[i], block.Key); + Assert.Equal(blocks[i], block.BlockText); + } + } - Benchmark.Start("Parsing extensions a few times (warmed up)", this.outputHelper); - for (int i = 0; i < 1000; i++) - { - subject.ParseExtensions(req, out index); - } + /// + /// The parse arguments_invalid. + /// + /// + /// The args. + /// + [Theory] + [InlineData("hello {{dawg}")] + [InlineData("hello {dawg}}")] + [InlineData("hello '{dawg}")] + [InlineData("hello {dawg'}")] + [InlineData("hello {dawg} {sweet}")] + [InlineData("hello {dawg} test{sweet}}")] + [InlineData("hello '{{dawg'}} test{sweet}")] + public void ParseArguments_invalid(string args) + { + var subject = new BaseFormatterImpl(); + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); + var ex = Assert.Throws(() => subject.ParseArguments(req)); + Assert.Equal(args, ex.SourceSnippet); + this.outputHelper.WriteLine(ex.Message); + } - Benchmark.End(this.outputHelper); + /// + /// The parse extensions. + /// + /// + /// The args. + /// + /// + /// The extension. + /// + /// + /// The value. + /// + /// + /// The expected index. + /// + [Theory] + [InlineData(" offset:3 boom", "offset", "3", 9)] + [InlineData("testie:dawg lel", "testie", "dawg", 11)] + public void ParseExtensions(string? args, string extension, string value, int expectedIndex) + { + var subject = new BaseFormatterImpl(); + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); - var actual = subject.ParseExtensions(req, out index); - Assert.NotEmpty(actual); - var first = actual.First(); - Assert.Equal(extension, first.Extension); - Assert.Equal(value, first.Value); - Assert.Equal(expectedIndex, index); - } + // Warmup + subject.ParseExtensions(req, out var index); - /// - /// The parse extensions_multiple. - /// - [Fact] - public void ParseExtensions_multiple() + Benchmark.Start("Parsing extensions a few times (warmed up)", this.outputHelper); + for (int i = 0; i < 1000; i++) { - var subject = new BaseFormatterImpl(); - int index; - var args = " offset:2 code:js "; - var expectedIndex = 17; + subject.ParseExtensions(req, out index); + } - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), null, null, args); + Benchmark.End(this.outputHelper); - var actual = subject.ParseExtensions(req, out index); - Assert.NotEmpty(actual); - var result = actual.First(); - Assert.Equal("offset", result.Extension); - Assert.Equal("2", result.Value); + var actual = subject.ParseExtensions(req, out index).ToList(); + Assert.NotEmpty(actual); + var first = actual.First(); + Assert.Equal(extension, first.Extension); + Assert.Equal(value, first.Value); + Assert.Equal(expectedIndex, index); + } + + /// + /// The parse extensions returns empty collection when formatter arguments is null. + /// + [Fact] + public void ParseExtensions_returns_empty_collection_when_formatter_arguments_is_null() + { + string? args = null; + var subject = new BaseFormatterImpl(); + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); + + var actual = subject.ParseExtensions(req, out var index); + + Assert.Empty(actual); + Assert.Equal(-1, index); + } - result = actual.ElementAt(1); - Assert.Equal("code", result.Extension); - Assert.Equal("js", result.Value); + /// + /// The parse extensions_multiple. + /// + [Fact] + public void ParseExtensions_multiple() + { + var subject = new BaseFormatterImpl(); + var args = " offset:2 code:js "; + var expectedIndex = 17; - Assert.Equal(expectedIndex, index); - } + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); - /// - /// The parse keyed blocks. - /// - /// - /// The args. - /// - /// - /// The keys. - /// - /// - /// The values. - /// - [Theory] - [MemberData(nameof(ParseKeyedBlocks_tests))] - public void ParseKeyedBlocks(string args, string[] keys, string[] values) - { - var subject = new BaseFormatterImpl(); - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), null, null, args); + var actual = subject.ParseExtensions(req, out var index).ToList(); + Assert.NotEmpty(actual); + var result = actual.First(); + Assert.Equal("offset", result.Extension); + Assert.Equal("2", result.Value); - // Warm-up - subject.ParseKeyedBlocks(req, 0); + result = actual.ElementAt(1); + Assert.Equal("code", result.Extension); + Assert.Equal("js", result.Value); - Benchmark.Start("Parsing keyed blocks..", this.outputHelper); - for (int i = 0; i < 10000; i++) - { - subject.ParseKeyedBlocks(req, 0); - } + Assert.Equal(expectedIndex, index); + } - Benchmark.End(this.outputHelper); + /// + /// The parse keyed blocks. + /// + /// + /// The args. + /// + /// + /// The keys. + /// + /// + /// The values. + /// + [Theory] + [MemberData(nameof(ParseKeyedBlocksTests))] + public void ParseKeyedBlocks(string? args, string[] keys, string[] values) + { + var subject = new BaseFormatterImpl(); + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); - var actual = subject.ParseKeyedBlocks(req, 0); - Assert.Equal(keys.Length, actual.Count()); - this.outputHelper.WriteLine("Input: " + args); - this.outputHelper.WriteLine("-----"); - for (int index = 0; index < actual.ToArray().Length; index++) - { - var keyedBlock = actual.ToArray()[index]; - var expectedKey = keys[index]; - var expectedValue = values[index]; - Assert.Equal(expectedKey, keyedBlock.Key); - Assert.Equal(expectedValue, keyedBlock.BlockText); - - this.outputHelper.WriteLine("Key: " + keyedBlock.Key); - this.outputHelper.WriteLine("Block: " + keyedBlock.BlockText); - } - } + // Warm-up + subject.ParseKeyedBlocks(req, 0); - /// - /// The parse keyed blocks unclosed_escape_sequence. - /// - /// - /// The args. - /// - [Theory] - [InlineData("male {he} other {'{they}")] - [InlineData("male {he} other {'# they}")] - public void ParseKeyedBlocks_unclosed_escape_sequence(string args) + Benchmark.Start("Parsing keyed blocks..", this.outputHelper); + for (int i = 0; i < 10000; i++) { - var subject = new BaseFormatterImpl(); - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), null, null, args); - - Assert.Throws(() => subject.ParseKeyedBlocks(req, 0)); + subject.ParseKeyedBlocks(req, 0); } - #endregion + Benchmark.End(this.outputHelper); - /// - /// The base formatter impl. - /// - private class BaseFormatterImpl : BaseFormatter + var actual = subject.ParseKeyedBlocks(req, 0).ToList(); + Assert.Equal(keys.Length, actual.Count()); + this.outputHelper.WriteLine("Input: " + args); + this.outputHelper.WriteLine("-----"); + for (int index = 0; index < actual.ToArray().Length; index++) { + var keyedBlock = actual.ToArray()[index]; + var expectedKey = keys[index]; + var expectedValue = values[index]; + Assert.Equal(expectedKey, keyedBlock.Key); + Assert.Equal(expectedValue, keyedBlock.BlockText); + + this.outputHelper.WriteLine("Key: " + keyedBlock.Key); + this.outputHelper.WriteLine("Block: " + keyedBlock.BlockText); } } + + /// + /// The parse keyed blocks unclosed_escape_sequence. + /// + /// + /// The args. + /// + [Theory] + [InlineData("male {he} other {'{they}")] + [InlineData("male {he} other {'# they}")] + [InlineData("male {he} other }")] + [InlineData("male {he} other {'")] + [InlineData("male {he} other {'{'")] + [InlineData("male{}} female{}")] + [InlineData("male haha")] + public void ParseKeyedBlocks_bad_formatting(string? args) + { + var subject = new BaseFormatterImpl(); + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), string.Empty, null, args); + + Assert.Throws(() => subject.ParseKeyedBlocks(req, 0)); + } + + #endregion + + /// + /// The base formatter impl. + /// + private class BaseFormatterImpl : BaseFormatter + { + } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/FormatterLibraryTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/FormatterLibraryTests.cs index 163888c..db33466 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/FormatterLibraryTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/FormatterLibraryTests.cs @@ -4,49 +4,50 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. -using System.Text; - using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Parsing; - -using Moq; - +using Jeffijoe.MessageFormat.Tests.TestHelpers; using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Formatting +namespace Jeffijoe.MessageFormat.Tests.Formatting; + +/// +/// The formatter library tests. +/// +public class FormatterLibraryTests { + #region Public Methods and Operators + /// - /// The formatter library tests. + /// The get formatter. /// - public class FormatterLibraryTests + [Fact] + public void GetFormatter() { - #region Public Methods and Operators - - /// - /// The get formatter. - /// - [Fact] - public void GetFormatter() - { - var subject = new FormatterLibrary(); - var mock1 = new Mock(); - var mock2 = new Mock(); - - var req = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), "test", "dawg", null); - subject.Add(mock1.Object); - subject.Add(mock2.Object); - - Assert.Throws(() => subject.GetFormatter(req)); - - mock2.Setup(x => x.CanFormat(req)).Returns(true); - var actual = subject.GetFormatter(req); - Assert.Same(mock2.Object, actual); - - mock1.Setup(x => x.CanFormat(req)).Returns(true); - actual = subject.GetFormatter(req); - Assert.Same(mock1.Object, actual); - } - - #endregion + var subject = new FormatterLibrary(); + + var req = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "dawg", null); + var formatter1 = new FakeFormatter(); + var formatter2 = new FakeFormatter(); + + subject.Add(formatter1); + subject.Add(formatter2); + + Assert.Throws(() => subject.GetFormatter(req)); + + formatter2.SetCanFormat(true); + + var actual = subject.GetFormatter(req); + Assert.Same(formatter2, actual); + + formatter1.SetCanFormat(true); + actual = subject.GetFormatter(req); + Assert.Same(formatter1, actual); } + + #endregion + + #region Fakes + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/DateFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/DateFormatterTests.cs new file mode 100644 index 0000000..5c33a00 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/DateFormatterTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; +using Jeffijoe.MessageFormat.Formatting; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters; + +public class DateFormatterTests +{ + private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en"); + + [Theory] + [InlineData("en-US", "1994-09-06T15:00:00Z", "9/6/1994")] + [InlineData("da-DK", "1994-09-06T15:00:00Z", "06.09.1994")] + public void DateFormatter_Short(string locale, string dateStr, string expected) + { + var mf = new MessageFormatter(); + var actual = mf.FormatMessage("{value, date}", new + { + value = DateTimeOffset.Parse(dateStr) + }, CultureInfo.GetCultureInfo(locale)); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("en-US", "1994-09-06T15:00:00Z", "Tuesday, September 6, 1994")] + [InlineData("da-DK", "1994-09-06T15:00:00Z", "tirsdag den 6. september 1994")] + public void DateFormatter_Full(string locale, string dateStr, string expected) + { + var mf = new MessageFormatter(); + var actual = mf.FormatMessage("{value, date, full}", new + { + value = DateTimeOffset.Parse(dateStr) + }, CultureInfo.GetCultureInfo(locale)); + + Assert.Equal(expected, actual); + } + + [Fact] + public void DateFormatter_UnsupportedStyle() + { + var mf = new MessageFormatter(); + Assert.Throws( + () => mf.FormatMessage("{value, date, long}", new + { + value = DateTimeOffset.UtcNow + })); + } + + [Fact] + public void DateFormatter_Custom() + { + var formatter = new CustomValueFormatters + { + Date = (culture, value, _, out formatted) => + { + // This is just a test, you probably shouldn't be doing this in real workloads. + formatted = ((FormattableString)$"{value:MMMM d 'in the year' yyyy}").ToString(culture); + return true; + } + }; + var mf = new MessageFormatter(customValueFormatter: formatter); + var actual = mf.FormatMessage("{value, date, long}", new + { + value = DateTimeOffset.Parse("1994-09-06T15:00:00Z") + }, En); + + Assert.Equal("September 6 in the year 1994", actual); + } +} diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/NumberFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/NumberFormatterTests.cs new file mode 100644 index 0000000..d52ccff --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/NumberFormatterTests.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using Jeffijoe.MessageFormat.Formatting; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters; + +public class NumberFormatterTests +{ + private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en"); + + [Theory] + [InlineData(69, "69")] + [InlineData(69.420, "69.42")] + [InlineData(123_456.789, "123,456.789")] + [InlineData(1234567.1234567, "1,234,567.123")] + public void NumberFormatter_Default(decimal number, string expected) + { + var mf = new MessageFormatter(); + // NOTE: The whitespace at the end is on purpose to cover whitespace tolerance in parsing. + var actual = mf.FormatMessage("{value, number }", new + { + value = number + }, En); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(69, "69.0000")] + [InlineData(69.420, "69.4200")] + public void NumberFormatter_Decimal_CustomFormat(decimal number, string expected) + { + var formatters = new CustomValueFormatters + { + Number = (culture, value, style, out formatted) => + { + formatted = string.Format(culture, $"{{0:{style}}}", value); + return true; + } + }; + var mf = new MessageFormatter(customValueFormatter: formatters); + + var actual = mf.FormatMessage("{value, number, 0.0000}", new + { + value = number + }, En); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(0.2, "20%")] + [InlineData(1.2, "120%")] + [InlineData(1234567.1234567, "123,456,712%")] + public void NumberFormatter_Percent(decimal number, string expected) + { + var mf = new MessageFormatter(); + + // NOTE: The inconsistent whitespace in the pattern is to cover whitespace tolerance in parsing. + var actual = mf.FormatMessage("{value, number,percent}", new + { + value = number + }, En); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(0.2, "0")] + [InlineData(1.2, "1")] + [InlineData(2.7, "3")] + [InlineData("2.7", "3")] + [InlineData("a string", "a string")] + [InlineData(true, "True")] + public void NumberFormatter_Integer(object? value, string expected) + { + var mf = new MessageFormatter(); + var actual = mf.FormatMessage("{value, number, integer}", new + { + value + }, En); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("en-US", 20, "$20.00")] + [InlineData("en-US", 99.99, "$99.99")] + [InlineData("da-DK", 99.99, "99,99 kr.")] + public void NumberFormatter_Currency(string locale, decimal number, string expected) + { + var mf = new MessageFormatter(); + + // NOTE: The inconsistent whitespace in the pattern is to cover whitespace tolerance in parsing. + var actual = mf.FormatMessage("{value, number, currency }", new + { + value = number + }, CultureInfo.GetCultureInfo(locale)); + + Assert.Equal(expected, actual); + } + + [Fact] + public void NumberFormatter_ThrowsIfStyleIsNotSupported() + { + const decimal Number = 12.34m; + var mf = new MessageFormatter(); + var ex = Assert.Throws(() => + mf.FormatMessage($"{{value, number, wow}}", + new + { + value = Number + }, En)); + Assert.Equal("value", ex.Variable); + Assert.Equal("number", ex.Format); + Assert.Equal("wow", ex.Style); + } + + [Fact] + public void NumberFormatter_BadInput_FallsBackToRegularFormat() + { + var mf = new MessageFormatter(); + + { + var actual = mf.FormatMessage($"{{value, number, currency}}", new + { + value = "a lot of money" + }, En); + + Assert.Equal("a lot of money", actual); + } + + { + var actual = mf.FormatMessage($"{{value, number, integer}}", new + { + value = "a lot of money" + }, En); + + Assert.Equal("a lot of money", actual); + } + + { + var actual = mf.FormatMessage($"{{value, number, integer}}", new + { + value = true + }, En); + + Assert.Equal("True", actual); + } + } +} diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs index 5bd014d..9ad7cc7 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs @@ -6,75 +6,115 @@ using System; using System.Collections.Generic; -using System.Text; - using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Formatting.Formatters; using Jeffijoe.MessageFormat.Parsing; using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters; + +/// +/// The plural formatter tests. +/// +public class PluralFormatterTests { + #region Public Methods and Operators + /// - /// The plural formatter tests. + /// The pluralize. /// - public class PluralFormatterTests + /// + /// The n. + /// + /// + /// The expected. + /// + [Theory] + [InlineData(0, "nothing")] + [InlineData(1, "just one")] + [InlineData(1337, "wow")] + public void Pluralize(double n, string expected) { - #region Public Methods and Operators - - /// - /// The pluralize. - /// - /// - /// The n. - /// - /// - /// The expected. - /// - [Theory] - [InlineData(0, "nothing")] - [InlineData(1, "just one")] - [InlineData(1337, "wow")] - public void Pluralize(double n, string expected) - { - var subject = new PluralFormatter(); - var args = new Dictionary { { "test", n } }; - var arguments = - new ParsedArguments( - new[] - { - new KeyedBlock("zero", "nothing"), - new KeyedBlock("one", "just one"), - new KeyedBlock("other", "wow") - }, - new FormatterExtension[0]); - var request = new FormatterRequest(new Literal(1, 1, 1, 1, new StringBuilder()), "test", "plural", null); - var actual = subject.Pluralize("en", arguments, Convert.ToDouble(args[request.Variable]), 0); - Assert.Equal(expected, actual); - } + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", n } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("=0", "nothing"), + new KeyedBlock("one", "just one"), + new KeyedBlock("other", "wow") + }, + Array.Empty()); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); + var actual = subject.Pluralize("en", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + Assert.Equal(expected, actual); + } - /// - /// The replace number literals. - /// - /// - /// The input. - /// - /// - /// The expected. - /// - [Theory] - [InlineData(@"Number '#1' has # results", "Number '#1' has 1337 results")] - [InlineData(@"Number '#'1 has # results", "Number '#'1 has 1337 results")] - [InlineData(@"Number '#'# has # results", "Number '#'1337 has 1337 results")] - [InlineData(@"# results", "1337 results")] - public void ReplaceNumberLiterals(string input, string expected) - { - var subject = new PluralFormatter(); - var actual = subject.ReplaceNumberLiterals(new StringBuilder(input), 1337); - Assert.Equal(expected, actual); - } + /// + /// The pluralize_defaults_to_en_locale_when_specified_locale_is_not_found + /// + [Fact] + public void Pluralize_defaults_to_root_locale_when_specified_locale_is_not_found() + { + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", 1 } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("=0", "nothing"), + new KeyedBlock("one", "just one"), + new KeyedBlock("other", "wow") // Root locale should resolve "1" to "other" + }, + Array.Empty()); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); + var actual = subject.Pluralize("unknown", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + Assert.Equal("wow", actual); + } + + /// + /// The pluralize_defaults_to_en_locale_when_specified_locale_is_not_found + /// + [Fact] + public void Pluralize_throws_when_missing_other_block() + { + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", 5 } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("=0", "nothing"), + new KeyedBlock("one", "just one") + }, + Array.Empty()); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); + Assert.Throws(() => subject.Pluralize(PluralRulesMetadata.RootLocale, PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); + } - #endregion + /// + /// The replace number literals. + /// + /// + /// The input. + /// + /// + /// The expected. + /// + [Theory] + [InlineData(@"Number '#1' has # results", "Number '#1' has 1337 results")] + [InlineData(@"Number '#'1 has # results", "Number '#'1 has 1337 results")] + [InlineData(@"Number '#'# has # results", "Number '#'1337 has 1337 results")] + [InlineData(@"Number '''#'''# has # results", "Number '''#'''1337 has 1337 results")] + [InlineData(@"# results", "1337 results")] + public void ReplaceNumberLiterals(string input, string expected) + { + var subject = new PluralFormatter(); + var actual = subject.ReplaceNumberLiterals(input, 1337); + Assert.Equal(expected, actual); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/SelectFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/SelectFormatterTests.cs index 88061b1..b5ef8fb 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/SelectFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/SelectFormatterTests.cs @@ -5,72 +5,88 @@ // Copyright (C) Jeff Hansen 2015. All rights reserved. using System.Collections.Generic; -using System.Text; - +using System.Globalization; using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Formatting.Formatters; using Jeffijoe.MessageFormat.Parsing; - -using Moq; - +using Jeffijoe.MessageFormat.Tests.TestHelpers; using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters; + +/// +/// The select formatter tests. +/// +public class SelectFormatterTests { + #region Public Properties + /// - /// The select formatter tests. + /// Gets the format_tests. /// - public class SelectFormatterTests + public static IEnumerable FormatTests { - #region Public Properties - - /// - /// Gets the format_tests. - /// - public static IEnumerable Format_tests + get { - get - { - yield return new object[] { "male {he said} female {she said} other {they said}", "male", "he said" }; - yield return new object[] { "male {he said} female {she said} other {they said}", "female", "she said" }; - yield return new object[] { "male {he said} female {she said} other {they said}", "dawg", "they said" }; - } + yield return new object[] { "male {he said} female {she said} other {they said}", "male", "he said" }; + yield return new object[] + { "male {he said} female {she said} other {they said}", "female", "she said" }; + yield return new object[] { "male {he said} female {she said} other {they said}", "dawg", "they said" }; } + } - #endregion + #endregion - #region Public Methods and Operators + #region Public Methods and Operators - /// - /// The format. - /// - /// - /// The formatter args. - /// - /// - /// The gender. - /// - /// - /// The expected block. - /// - [Theory] - [MemberData(nameof(Format_tests))] - public void Format(string formatterArgs, string gender, string expectedBlock) - { - var subject = new SelectFormatter(); - var messageFormatterMock = new Mock(); - messageFormatterMock.Setup(x => x.FormatMessage(It.IsAny(), It.IsAny>())) - .Returns((string input, Dictionary a) => input); - var req = new FormatterRequest( - new Literal(1, 1, 1, 1, new StringBuilder()), - "gender", - "select", - formatterArgs); - var args = new Dictionary { { "gender", gender } }; - var result = subject.Format("en", req, args, gender, messageFormatterMock.Object); - Assert.Equal(expectedBlock, result); - } + /// + /// The format. + /// + /// + /// The formatter args. + /// + /// + /// The gender. + /// + /// + /// The expected block. + /// + [Theory] + [MemberData(nameof(FormatTests))] + public void Format(string formatterArgs, string gender, string expectedBlock) + { + var subject = new SelectFormatter(); + var messageFormatter = new FakeMessageFormatter(); + var req = new FormatterRequest( + new Literal(1, 1, 1, 1, ""), + "gender", + "select", + formatterArgs); + var args = new Dictionary { { "gender", gender } }; + var result = subject.Format(CultureInfo.GetCultureInfo("en"), req, args, gender, messageFormatter); + Assert.Equal(expectedBlock, result); + } - #endregion + /// + /// Verifies that format throws when no other option is given. + /// + [Fact] + public void VerifyFormatThrowsWhenNoOtherOptionIsGiven() + { + var subject = new SelectFormatter(); + var messageFormatter = new FakeMessageFormatter(); + var req = new FormatterRequest( + new Literal(1, 1, 1, 1, ""), + "gender", + "select", + "male {he} female{she}"); + var args = new Dictionary { { "gender", "non-binary" } }; + + Assert.Throws(() => + { + subject.Format(CultureInfo.GetCultureInfo("en"), req, args, "non-binary", messageFormatter); + }); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/TimeFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/TimeFormatterTests.cs new file mode 100644 index 0000000..04f5715 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/TimeFormatterTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; +using Jeffijoe.MessageFormat.Formatting; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters; + +public partial class TimeFormatterTests +{ + private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en"); + + [Theory] + [InlineData("en-US", "1994-09-06T15:01:23Z", "3:01 PM")] + [InlineData("da-DK", "1994-09-06T15:01:23Z", "15.01")] + public void TimeFormatter_Short(string locale, string dateStr, string expected) + { + var mf = new MessageFormatter(); + var actual = mf.FormatMessage("{value, time, short}", new + { + value = DateTimeOffset.Parse(dateStr) + }, CultureInfo.GetCultureInfo(locale)); + + // Replacing all whitespace due to a difference in formatting on macOS vs Linux. + expected = Normalize(expected); + actual = Normalize(actual); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("en-US", "1994-09-06T15:01:23Z", "3:01:23 PM")] + [InlineData("da-DK", "1994-09-06T15:01:23Z", "15.01.23")] + public void TimeFormatter_Default(string locale, string dateStr, string expected) + { + var mf = new MessageFormatter(); + var actual = mf.FormatMessage("{value, time}", new + { + value = DateTimeOffset.Parse(dateStr) + }, CultureInfo.GetCultureInfo(locale)); + + // Replacing all whitespace due to a difference in formatting on macOS vs Linux. + expected = Normalize(expected); + actual = Normalize(actual); + Assert.Equal(expected, actual); + } + + [Fact] + public void TimeFormatter_UnsupportedStyle() + { + var mf = new MessageFormatter(); + Assert.Throws( + () => mf.FormatMessage("{value, time, lol}", new + { + value = DateTimeOffset.UtcNow + })); + } + + [Fact] + public void TimeFormatter_Custom() + { + var formatter = new CustomValueFormatters + { + Time = (_, value, _, out formatted) => + { + formatted = $"{value:hmm} nice"; + return true; + } + }; + var mf = new MessageFormatter(customValueFormatter: formatter); + var actual = mf.FormatMessage("{value, time, long}", new + { + value = DateTimeOffset.Parse("1994-09-06T16:20:09Z") + }, En); + + Assert.Equal("420 nice", actual); + } + + [GeneratedRegex("\\s")] + private static partial Regex WhitespaceRegex(); + + private static string Normalize(string input) + { + return WhitespaceRegex().Replace(input, string.Empty); + } +} diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/VariableFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/VariableFormatterTests.cs index 584df63..fbd8f44 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/VariableFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/VariableFormatterTests.cs @@ -5,92 +5,89 @@ // Copyright (C) Jeff Hansen 2015. All rights reserved. using System.Collections.Generic; -using System.Text; - +using System.Globalization; using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Formatting.Formatters; using Jeffijoe.MessageFormat.Parsing; - -using Moq; +using Jeffijoe.MessageFormat.Tests.TestHelpers; using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Tests.Formatting.Formatters; + +/// +/// The variable formatter tests. +/// +public class VariableFormatterTests { + #region Fields + + /// + /// The subject. + /// + private readonly VariableFormatter subject; + + /// + /// The fake message formatter. + /// + private readonly IMessageFormatter formatter; + + #endregion + + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + public VariableFormatterTests() + { + this.formatter = new FakeMessageFormatter(); + this.subject = new VariableFormatter(); + } + + #endregion + + #region Public Methods and Operators + /// - /// The variable formatter tests. + /// Verifies that an empty string is returned when the argument is null. /// - public class VariableFormatterTests + [Fact] + public void VerifyAnEmptyStringIsReturnedWhenTheArgumentIsNull() { - #region Fields - - /// - /// The formatter mock. - /// - private readonly Mock formatterMock; - - /// - /// The subject. - /// - private readonly VariableFormatter subject; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - public VariableFormatterTests() - { - this.formatterMock = new Mock(); - this.subject = new VariableFormatter(); - } - - #endregion - - #region Public Methods and Operators - - /// - /// Verifies that an empty string is returned when the argument is null. - /// - [Fact] - public void VerifyAnEmptyStringIsReturnedWhenTheArgumentIsNull() - { - var req = CreateRequest(); - var args = new Dictionary(); - - Assert.Equal(string.Empty, this.subject.Format("en", req, args, null, this.formatterMock.Object)); - } - - /// - /// Verifies that the value from the given arguments is returned as a string. - /// - [Fact] - public void VerifyTheValueFromTheGivenArgumentsIsReturnedAsAString() - { - var req = CreateRequest(); - var args = new Dictionary(); - - Assert.Equal("is good", this.subject.Format("en", req, args, "is good", this.formatterMock.Object)); - } - - #endregion - - #region Methods - - /// - /// Creates the request. - /// - /// - /// The . - /// - private static FormatterRequest CreateRequest() - { - var req = new FormatterRequest(new Literal(1, 10, 1, 1, new StringBuilder()), "test", null, null); - return req; - } - - #endregion + var req = CreateRequest(); + var args = new Dictionary(); + + Assert.Equal(string.Empty, this.subject.Format(CultureInfo.GetCultureInfo("en"), req, args, null, this.formatter)); + } + + /// + /// Verifies that the value from the given arguments is returned as a string. + /// + [Fact] + public void VerifyTheValueFromTheGivenArgumentsIsReturnedAsAString() + { + var req = CreateRequest(); + var args = new Dictionary(); + + Assert.Equal("is good", this.subject.Format(CultureInfo.GetCultureInfo("en"), req, args, "is good", this.formatter)); } + + #endregion + + #region Methods + + /// + /// Creates the request. + /// + /// + /// The . + /// + private static FormatterRequest CreateRequest() + { + var req = new FormatterRequest(new Literal(1, 10, 1, 1, ""), "test", null, null); + return req; + } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Helpers/CharHelperTests.cs b/src/Jeffijoe.MessageFormat.Tests/Helpers/CharHelperTests.cs index cef0319..361d681 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Helpers/CharHelperTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Helpers/CharHelperTests.cs @@ -8,31 +8,30 @@ using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Helpers +namespace Jeffijoe.MessageFormat.Tests.Helpers; + +/// +/// The char helper tests. +/// +public class CharHelperTests { + #region Public Methods and Operators + /// - /// The char helper tests. + /// The is alpha numeric. /// - public class CharHelperTests + [Fact] + public void IsAlphaNumeric() { - #region Public Methods and Operators - - /// - /// The is alpha numeric. - /// - [Fact] - public void IsAlphaNumeric() - { - Assert.True('a'.IsAlphaNumeric()); - Assert.True('A'.IsAlphaNumeric()); - Assert.True('0'.IsAlphaNumeric()); - Assert.True('1'.IsAlphaNumeric()); - Assert.False('ä'.IsAlphaNumeric()); - Assert.False('ø'.IsAlphaNumeric()); - Assert.False('æ'.IsAlphaNumeric()); - Assert.False('å'.IsAlphaNumeric()); - } - - #endregion + Assert.True('a'.IsAlphaNumeric()); + Assert.True('A'.IsAlphaNumeric()); + Assert.True('0'.IsAlphaNumeric()); + Assert.True('1'.IsAlphaNumeric()); + Assert.False('ä'.IsAlphaNumeric()); + Assert.False('ø'.IsAlphaNumeric()); + Assert.False('æ'.IsAlphaNumeric()); + Assert.False('å'.IsAlphaNumeric()); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs b/src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs new file mode 100644 index 0000000..e785d5f --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs @@ -0,0 +1,61 @@ +using Jeffijoe.MessageFormat.Formatting.Formatters; +using Jeffijoe.MessageFormat.Helpers; +using System.Linq; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.Helpers; + +/// +/// The locale helper tests. +/// +public class LocaleHelperTests +{ + /// + /// Tests that both '-' and '_' are supported when extracting the base language. + /// + [Fact] + public void GetInheritanceChain_HandlesBothSeparators() + { + Assert.Equal( + ["en-US", "en", PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain("en-US").ToList() + ); + + Assert.Equal( + ["en_US", "en", PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain("en_US").ToList() + ); + } + + /// + /// Confirms that our implementation only returns the original locale, + /// the language, and the root. + /// + /// + /// This is a perf optimization given the CLDR data set we're using. + /// + [Fact] + public void GetInheritanceChain_SkipsIntermediateTags() + { + Assert.Equal( + ["th-TH-u-nu-thai", "th", PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain("th-TH-u-nu-thai").ToList() + ); + } + + [Theory] + [InlineData("")] + [InlineData("-")] + [InlineData("_")] + [InlineData("x")] + [InlineData("x-")] + [InlineData("x-test")] + [InlineData("i-test")] + public void GetInheritanceChain_HandlesBadInput(string input) + { + Assert.Equal( + [PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain(input).ToList() + ); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Helpers/ObjectHelperTests.cs b/src/Jeffijoe.MessageFormat.Tests/Helpers/ObjectHelperTests.cs index 1a32ba6..002e1b4 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Helpers/ObjectHelperTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Helpers/ObjectHelperTests.cs @@ -12,118 +12,119 @@ using Xunit; using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests.Helpers +// ReSharper disable UnusedMember.Local + +namespace Jeffijoe.MessageFormat.Tests.Helpers; + +/// +/// The object helper tests. +/// +public class ObjectHelperTests { + #region Fields + /// - /// The object helper tests. + /// The output helper. /// - public class ObjectHelperTests - { - #region Fields - - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; + private readonly ITestOutputHelper outputHelper; - #endregion + #endregion - #region Constructors and Destructors + #region Constructors and Destructors - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public ObjectHelperTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public ObjectHelperTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } - #endregion + #endregion - #region Public Methods and Operators + #region Public Methods and Operators - /// - /// The get properties_anonymous_and_dynamic. - /// - [Fact] - public void GetProperties_anonymous_and_dynamic() - { - var obj = new { Test = "wee", Toast = "woo" }; - var actual = ObjectHelper.GetProperties(obj); - Assert.Equal(2, actual.Count()); + /// + /// The get properties_anonymous_and_dynamic. + /// + [Fact] + public void GetProperties_anonymous_and_dynamic() + { + var obj = new { Test = "wee", Toast = "woo" }; + var actual = ObjectHelper.GetProperties(obj); + Assert.Equal(2, actual.Count()); - dynamic d = new { Cool = "sweet" }; - actual = ObjectHelper.GetProperties(d); - Assert.Single(actual); - } + dynamic d = new { Cool = "sweet" }; + actual = ObjectHelper.GetProperties(d); + Assert.Single(actual); + } - /// - /// The get properties_base_and_derived. - /// - [Fact] - public void GetProperties_base_and_derived() - { - var actual = ObjectHelper.GetProperties(new Base()); - Assert.Single(actual); + /// + /// The get properties_base_and_derived. + /// + [Fact] + public void GetProperties_base_and_derived() + { + var actual = ObjectHelper.GetProperties(new Base()); + Assert.Single(actual); - actual = ObjectHelper.GetProperties(new Derived()); - Assert.Equal(2, actual.Count()); - } + actual = ObjectHelper.GetProperties(new Derived()); + Assert.Equal(2, actual.Count()); + } - /// - /// The to dictionary. - /// - [Fact] - public void ToDictionary() + /// + /// The to dictionary. + /// + [Fact] + public void ToDictionary() + { + var obj = new { name = "test", num = 1337 }; + var actual = obj.ToDictionary(); + Assert.Equal(2, actual.Count); + Assert.Equal("test", actual["name"]); + Assert.Equal(1337, actual["num"]); + + Benchmark.Start("Converting object to dictionary..", this.outputHelper); + for (int i = 0; i < 10000; i++) { - var obj = new { name = "test", num = 1337 }; - var actual = obj.ToDictionary(); - Assert.Equal(2, actual.Count); - Assert.Equal("test", actual["name"]); - Assert.Equal(1337, actual["num"]); - - Benchmark.Start("Converting object to dictionary..", this.outputHelper); - for (int i = 0; i < 10000; i++) - { - obj.ToDictionary(); - } - - Benchmark.End(this.outputHelper); + obj.ToDictionary(); } - #endregion + Benchmark.End(this.outputHelper); + } + + #endregion + + /// + /// The base. + /// + private class Base + { + #region Public Properties /// - /// The base. + /// Gets or sets the prop 1. /// - private class Base - { - #region Public Properties + public string? Prop1 { get; set; } - /// - /// Gets or sets the prop 1. - /// - public string Prop1 { get; set; } + #endregion + } - #endregion - } + /// + /// The derived. + /// + private class Derived : Base + { + #region Public Properties /// - /// The derived. + /// Gets or sets the prop 2. /// - private class Derived : Base - { - #region Public Properties + public int Prop2 { get; set; } - /// - /// Gets or sets the prop 2. - /// - public int Prop2 { get; set; } - - #endregion - } + #endregion } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Helpers/StringBuilderHelperTests.cs b/src/Jeffijoe.MessageFormat.Tests/Helpers/StringBuilderHelperTests.cs index a47fe8d..fedfdd1 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Helpers/StringBuilderHelperTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Helpers/StringBuilderHelperTests.cs @@ -10,78 +10,77 @@ using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Helpers +namespace Jeffijoe.MessageFormat.Tests.Helpers; + +/// +/// The string builder helper tests. +/// +public class StringBuilderHelperTests { + #region Public Methods and Operators + /// - /// The string builder helper tests. + /// The contains. /// - public class StringBuilderHelperTests + /// + /// The src. + /// + /// + /// The c. + /// + /// + /// The expected. + /// + [Theory] + [InlineData("hello ", ' ', true)] + [InlineData("hello ", 'l', true)] + [InlineData("hello ", 'p', false)] + public void Contains(string src, char c, bool expected) { - #region Public Methods and Operators - - /// - /// The contains. - /// - /// - /// The src. - /// - /// - /// The c. - /// - /// - /// The expected. - /// - [Theory] - [InlineData("hello ", ' ', true)] - [InlineData("hello ", 'l', true)] - [InlineData("hello ", 'p', false)] - public void Contains(string src, char c, bool expected) - { - Assert.Equal(expected, new StringBuilder(src).Contains(c)); - } - - /// - /// The contains whitespace. - /// - /// - /// The src. - /// - /// - /// The expected. - /// - [Theory] - [InlineData(" hello", true)] - [InlineData(" hello ", true)] - [InlineData("hello ", true)] - [InlineData("Hi", false)] - public void ContainsWhitespace(string src, bool expected) - { - Assert.Equal(expected, new StringBuilder(src).ContainsWhitespace()); - } + Assert.Equal(expected, new StringBuilder(src).Contains(c)); + } - /// - /// The trim whitespace. - /// - /// - /// The input. - /// - /// - /// The expected. - /// - [Theory] - [InlineData(" dawg ", "dawg")] - [InlineData(" dawg dawg ", "dawg dawg")] - [InlineData(" dawg dawg", "dawg dawg")] - [InlineData("dawg dawg ", "dawg dawg")] - [InlineData(" dawg dawg ", "dawg dawg")] - [InlineData(" dawg dawg", "dawg dawg")] - [InlineData("dawg dawg", "dawg dawg")] - public void TrimWhitespace(string input, string expected) - { - string actual = new StringBuilder(input).TrimWhitespace().ToString(); - Assert.Equal(expected, actual); - } + /// + /// The contains whitespace. + /// + /// + /// The src. + /// + /// + /// The expected. + /// + [Theory] + [InlineData(" hello", true)] + [InlineData(" hello ", true)] + [InlineData("hello ", true)] + [InlineData("Hi", false)] + public void ContainsWhitespace(string src, bool expected) + { + Assert.Equal(expected, new StringBuilder(src).ContainsWhitespace()); + } - #endregion + /// + /// The trim whitespace. + /// + /// + /// The input. + /// + /// + /// The expected. + /// + [Theory] + [InlineData(" dawg ", "dawg")] + [InlineData(" dawg dawg ", "dawg dawg")] + [InlineData(" dawg dawg", "dawg dawg")] + [InlineData("dawg dawg ", "dawg dawg")] + [InlineData(" dawg dawg ", "dawg dawg")] + [InlineData(" dawg dawg", "dawg dawg")] + [InlineData("dawg dawg", "dawg dawg")] + public void TrimWhitespace(string input, string expected) + { + string actual = new StringBuilder(input).TrimWhitespace().ToString(); + Assert.Equal(expected, actual); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Jeffijoe.MessageFormat.Tests.csproj b/src/Jeffijoe.MessageFormat.Tests/Jeffijoe.MessageFormat.Tests.csproj index 5368cf0..31baa32 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Jeffijoe.MessageFormat.Tests.csproj +++ b/src/Jeffijoe.MessageFormat.Tests/Jeffijoe.MessageFormat.Tests.csproj @@ -1,21 +1,25 @@  - net452 True MessageFormat.snk False + default + enable + net10.0 - - - - - - - - + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -24,6 +28,7 @@ + diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterCachingTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterCachingTests.cs index 6554720..be7f893 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterCachingTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterCachingTests.cs @@ -4,147 +4,46 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. -using System; -using System.Collections.Generic; -using System.Text; - using Jeffijoe.MessageFormat.Formatting; -using Jeffijoe.MessageFormat.Helpers; -using Jeffijoe.MessageFormat.Parsing; - -using Moq; - +using Jeffijoe.MessageFormat.Tests.TestHelpers; using Xunit; -using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests +namespace Jeffijoe.MessageFormat.Tests; + +/// +/// The message formatter_caching_tests. +/// +public class MessageFormatterCachingTests { + #region Public Methods and Operators + /// - /// The message formatter_caching_tests. + /// The format message_caches_reused_pattern. /// - public class MessageFormatterCachingTests + [Fact] + public void FormatMessage_caches_reused_pattern() { - #region Fields - - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public MessageFormatterCachingTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } - - #endregion - - #region Public Methods and Operators - - /// - /// The format message_caches_reused_pattern. - /// - [Fact] - public void FormatMessage_caches_reused_pattern() - { - var parserMock = new Mock(); - var realParser = new PatternParser(new LiteralParser()); - parserMock.Setup(x => x.Parse(It.IsAny())) - .Returns((StringBuilder sb) => realParser.Parse(sb)); - var library = new FormatterLibrary(); - - var subject = new MessageFormatter(patternParser: parserMock.Object, library: library, useCache: true); - - var pattern = "Hi {gender, select, male {Sir} female {Ma'am}}!"; - var actual = subject.FormatMessage(pattern, new { gender = "male" }); - Assert.Equal("Hi Sir!", actual); - - // '2' because it did not format "Ma'am" yet. - parserMock.Verify(x => x.Parse(It.IsAny()), Times.Exactly(2)); - - actual = subject.FormatMessage(pattern, new { gender = "female" }); - Assert.Equal("Hi Ma'am!", actual); - parserMock.Verify(x => x.Parse(It.IsAny()), Times.Exactly(3)); - - // '3' because it has cached all options - actual = subject.FormatMessage(pattern, new { gender = "female" }); - Assert.Equal("Hi Ma'am!", actual); - parserMock.Verify(x => x.Parse(It.IsAny()), Times.Exactly(3)); - } - - /// - /// The format message_with_cache_benchmark. - /// - [Fact] - public void FormatMessage_with_cache_benchmark() - { - var subject = new MessageFormatter(true); - this.Benchmark(subject); - } - - /// - /// The format message_without_cache_benchmark. - /// - [Fact] - public void FormatMessage_without_cache_benchmark() - { - var subject = new MessageFormatter(false); - this.Benchmark(subject); - } - - #endregion + var parser = new TrackingPatternParser(); + var library = new FormatterLibrary(); - #region Methods + var subject = new MessageFormatter(patternParser: parser, library: library, useCache: true); - /// - /// The benchmark. - /// - /// - /// The subject. - /// - private void Benchmark(MessageFormatter subject) - { - var pattern = "\r\n----\r\nOh {name}? And if we were " + "to surround {gender, select, " + "male {his} " - + "female {her}" + "} name with '{' and '}', it would look " - + "like '{'{name}'}'? Yeah, I know {gender, select, " + "male {him} " + "female {her}" - + "}. {gender, select, " + "male {He's}" + "female {She's}" + "} got {messageCount, plural, " - + "zero {no messages}" + "one {just one message}" + "=42 {a universal amount of messages}" - + "other {uuhm... let's see.. Oh yeah, # messages - and here's a pound: '#'}" + "}!"; - int iterations = 100000; - var args = new Dictionary[iterations]; - var rnd = new Random(); - for (int i = 0; i < iterations; i++) - { - var val = rnd.Next(50); - args[i] = - new - { - gender = val % 2 == 0 ? "male" : "female", - name = val % 2 == 0 ? "Jeff" : "Amanda", - messageCount = val - }.ToDictionary(); - } + var pattern = "Hi {gender, select, male {Sir} female {Ma'am}}!"; + var actual = subject.FormatMessage(pattern, new { gender = "male" }); + Assert.Equal("Hi Sir!", actual); - TestHelpers.Benchmark.Start("Formatting message " + iterations + " times, no warm-up.", this.outputHelper); - var output = new StringBuilder(); - for (int i = 0; i < iterations; i++) - { - output.AppendLine(subject.FormatMessage(pattern, args[i])); - } + // '2' because it did not format "Ma'am" yet. + Assert.Equal(2, parser.ParseCount); - TestHelpers.Benchmark.End(this.outputHelper); - this.outputHelper.WriteLine(output.ToString()); - } + actual = subject.FormatMessage(pattern, new { gender = "female" }); + Assert.Equal("Hi Ma'am!", actual); + Assert.Equal(3, parser.ParseCount); - #endregion + // '3' because it has cached all options + actual = subject.FormatMessage(pattern, new { gender = "female" }); + Assert.Equal("Hi Ma'am!", actual); + Assert.Equal(3, parser.ParseCount); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs index 551f37e..c0b28ff 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs @@ -1,509 +1,567 @@ -// MessageFormat for .NET +// MessageFormat for .NET // - MessageFormatter_full_integration_tests.cs // // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. using System.Collections.Generic; - +using System.Globalization; using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Tests.TestHelpers; - using Xunit; using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests +namespace Jeffijoe.MessageFormat.Tests; + +/// +/// The message formatter_full_integration_tests. +/// +public class MessageFormatterFullIntegrationTests { + #region Fields + + private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en"); + private static readonly CultureInfo EnUs = CultureInfo.GetCultureInfo("en-US"); + private static readonly CultureInfo DaDk = CultureInfo.GetCultureInfo("da-DK"); + /// - /// The message formatter_full_integration_tests. + /// The output helper. /// - public class MessageFormatterFullIntegrationTests - { - #region Fields - - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; + private readonly ITestOutputHelper outputHelper; - #endregion + #endregion - #region Constructors and Destructors + #region Constructors and Destructors - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public MessageFormatterFullIntegrationTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public MessageFormatterFullIntegrationTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } - #endregion + #endregion - #region Public Properties + #region Public Properties - public static IEnumerable EscapingTests + public static IEnumerable EscapingTests + { + get { - get - { - yield return - new object[] - { - "This '{isn''t}' obvious", - new Dictionary(), - "This {isn't} obvious" - }; - yield return - new object[] - { - "Anna's house has '{0} and # in the roof' and {NUM_COWS} cows.", - new Dictionary { { "NUM_COWS", 5 } }, - "Anna's house has {0} and # in the roof and 5 cows." - }; - yield return - new object[] - { - "Anna's house has '{'0'} and # in the roof' and {NUM_COWS} cows.", - new Dictionary { { "NUM_COWS", 5 } }, - "Anna's house has {0} and # in the roof and 5 cows." - }; - yield return - new object[] - { - "Anna's house has '{0}' and '# in the roof' and {NUM_COWS} cows.", - new Dictionary { { "NUM_COWS", 5 } }, - "Anna's house has {0} and # in the roof and 5 cows." - }; - yield return - new object[] - { - "Anna's house 'has {NUM_COWS} cows'.", - new Dictionary { { "NUM_COWS", 5 } }, - "Anna's house 'has 5 cows'." - }; - yield return - new object[] - { - "Anna''s house a'{''''b'", - new Dictionary(), - "Anna's house a{''b" - }; - yield return - new object[] - { - "a''{NUM_COWS}'b", - new Dictionary { { "NUM_COWS", 5 } }, - "a'5'b" - }; - yield return - new object[] - { - "a'{NUM_COWS}'b'", - new Dictionary { { "NUM_COWS", 5 } }, - "a{NUM_COWS}b'" - }; - yield return - new object[] - { - "These '{'braces'}' and thoses '{braces}' ain''t not escaped, which makes a total of {braces, plural, one {a single pair} other {'#'# (=#) pairs}} of escaped braces.", - new Dictionary { { "braces", 2 } }, - "These {braces} and thoses {braces} ain't not escaped, which makes a total of #2 (=2) pairs of escaped braces." - }; - yield return - new object[] - { - "{num, plural, =1 {1} other {'#'{num, plural, =1 {1} other {'{'#'#'#'}'}}}}", - new Dictionary { { "num", 2 } }, - "#{2#2}" - }; - } + yield return + new object[] + { + "This '{isn''t}' obvious", + new Dictionary(), + "This {isn't} obvious" + }; + yield return + new object[] + { + "Anna's house has '{0} and # in the roof' and {NUM_COWS} cows.", + new Dictionary { { "NUM_COWS", 5 } }, + "Anna's house has {0} and # in the roof and 5 cows." + }; + yield return + new object[] + { + "Anna's house has '{'0'} and # in the roof' and {NUM_COWS} cows.", + new Dictionary { { "NUM_COWS", 5 } }, + "Anna's house has {0} and # in the roof and 5 cows." + }; + yield return + new object[] + { + "Anna's house has '{0}' and '# in the roof' and {NUM_COWS} cows.", + new Dictionary { { "NUM_COWS", 5 } }, + "Anna's house has {0} and # in the roof and 5 cows." + }; + yield return + new object[] + { + "Anna's house 'has {NUM_COWS} cows'.", + new Dictionary { { "NUM_COWS", 5 } }, + "Anna's house 'has 5 cows'." + }; + yield return + new object[] + { + "Anna''s house a'{''''b'", + new Dictionary(), + "Anna's house a{''b" + }; + yield return + new object[] + { + "a''{NUM_COWS}'b", + new Dictionary { { "NUM_COWS", 5 } }, + "a'5'b" + }; + yield return + new object[] + { + "a'{NUM_COWS}'b'", + new Dictionary { { "NUM_COWS", 5 } }, + "a{NUM_COWS}b'" + }; + yield return + new object[] + { + "These '{'braces'}' and thoses '{braces}' ain''t not escaped, which makes a total of {braces, plural, one {a single pair} other {'#'# (=#) pairs}} of escaped braces.", + new Dictionary { { "braces", 2 } }, + "These {braces} and thoses {braces} ain't not escaped, which makes a total of #2 (=2) pairs of escaped braces." + }; + yield return + new object[] + { + "{num, plural, =1 {1} other {'#'{num, plural, =1 {1} other {'{'#'#'#'}'}}}}", + new Dictionary { { "num", 2 } }, + "#{2#2}" + }; + yield return + new object[] + { + "'''{'''", + new Dictionary(), + "'{'" + }; + // yield return + // new object[] + // { + // "{num, plural, =1 {1} other {'''{'''#'''}'''}}", + // new Dictionary { { "num", 2 } }, + // "'{'2'}'" + // }; } + } - /// - /// Gets the tests. - /// - public static IEnumerable Tests + /// + /// Gets the tests. + /// + public static IEnumerable Tests + { + get { - get - { - const string Case1 = @"{gender, select, + const string Case1 = @"{gender, select, male {He - '{'{name}'}' -} female {She - '{'{name}'}' -} other {They} } said: You're pretty cool!"; - const string Case2 = @"{gender, select, + const string Case2 = @"{gender, select, male {He - '{'{name}'}' -} female {She - '{'{name}'}' -} other {They} - } said: You have {count, plural, + } said: You have {count, plural, zero {no notifications} one {just one notification} =42 {a universal amount of notifications} other {# notifications} }. Have a nice day!"; - const string Case3 = @"You have {count, plural, + const string Case3 = @"You have {count, plural, zero {no notifications} one {just one notification} =42 {a universal amount of notifications} other {# notifications} }. Have a nice day!"; - const string Case4 = @"{gender, select, + const string Case4 = @"{gender, select, male {He} female {She} other {They} - } said: You have {count, plural, + } said: You have {count, plural, zero {no notifications} one {just one notification} =42 {a universal amount of notifications} other {# notifications} }. Have a nice day!"; - // Please take the following sample in the spirit it was intended. :) - const string Case5 = @"{gender, select, - male {He (who has {genitals, plural, + // Please take the following sample in the spirit it was intended. :) + const string Case5 = @"{gender, select, + male {He (who has {genitals, plural, zero {no testicles} one {just one testicle} =2 {a normal amount of testicles} other {the insane amount of # testicles} })} - female {She (who has {genitals, plural, + female {She (who has {genitals, plural, zero {no boobies} one {just one boob} =2 {a pair of lovelies} other {the freakish amount of # boobies} })} other {They} - } said: You have {count, plural, + } said: You have {count, plural, zero {no notifications} one {just one notification} =42 {a universal amount of notifications} other {# notifications} }. Have a nice day!"; - const string Case6 = @"You {count, plural, offset:1, + const string Case6 = @"You {count, plural, offset:1, =0{didn't add this to your profile} =1{added this to your profile} one {and one other person added this to their profile} other {and # others added this to their profiles} }."; - yield return - new object[] - { - Case1, - new Dictionary { { "gender", "male" }, { "name", "Jeff" } }, - "He - {Jeff} - said: You're pretty cool!" - }; - yield return - new object[] - { - Case2, - new Dictionary { { "gender", "male" }, { "name", "Jeff" }, { "count", 0 } }, - "He - {Jeff} - said: You have no notifications. Have a nice day!" - }; - yield return - new object[] - { - Case2, - new Dictionary { { "gender", "female" }, { "name", "Amanda" }, { "count", 1 } }, - "She - {Amanda} - said: You have just one notification. Have a nice day!" - }; - yield return - new object[] - { - Case2, - new Dictionary { { "gender", "uni" }, { "count", 42 } }, - "They said: You have a universal amount of notifications. Have a nice day!" - }; - yield return - new object[] - { - Case3, - new Dictionary { { "count", 5 } }, - "You have 5 notifications. Have a nice day!" - }; - yield return - new object[] - { - Case4, - new Dictionary { { "count", 5 }, { "gender", "male" } }, - "He said: You have 5 notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 5 }, { "gender", "male" }, { "genitals", 0 } }, - "He (who has no testicles) said: You have 5 notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 5 }, { "gender", "female" }, { "genitals", 0 } }, - "She (who has no boobies) said: You have 5 notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 0 }, { "gender", "female" }, { "genitals", 1 } }, - "She (who has just one boob) said: You have no notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 0 }, { "gender", "female" }, { "genitals", 2 } }, - "She (who has a pair of lovelies) said: You have no notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 0 }, { "gender", "female" }, { "genitals", 102 } }, - "She (who has the freakish amount of 102 boobies) said: You have no notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 42 }, { "gender", "female" }, { "genitals", 102 } }, - "She (who has the freakish amount of 102 boobies) said: You have a universal amount of notifications. Have a nice day!" - }; - yield return - new object[] - { - Case5, - new Dictionary { { "count", 1 }, { "gender", "male" }, { "genitals", 102 } }, - "He (who has the insane amount of 102 testicles) said: You have just one notification. Have a nice day!" - }; - - // Case from https://github.com/jeffijoe/messageformat.net/issues/2 - yield return - new object[] - { - "{nbrAttachments, plural, zero {} one {{nbrAttachmentsFmt} attachment} other {{nbrAttachmentsFmt} attachments}}", - new Dictionary { { "nbrAttachments", 0 }, { "nbrAttachmentsFmt", "wut" } }, - string.Empty - }; - - // Following 2 cases from https://github.com/jeffijoe/messageformat.net/issues/4 - yield return - new object[] - { - "{maybeCount}", - new Dictionary { { "maybeCount", null } }, - string.Empty - }; - yield return - new object[] - { - "{maybeCount}", - new Dictionary { { "maybeCount", (int?)2 } }, - "2" - }; - yield return - new object[] - { - Case6, - new Dictionary { { "count", 0 } }, - "You didn't add this to your profile." - }; - yield return - new object[] - { - Case6, - new Dictionary { { "count", 1 } }, - "You added this to your profile." - }; - yield return - new object[] - { - Case6, - new Dictionary { { "count", 2 } }, - "You and one other person added this to their profile." - }; - yield return - new object[] - { - Case6, - new Dictionary { { "count", 3 } }, - "You and 2 others added this to their profiles." - }; - } - } + const string Case7 = @"Your {count, selectordinal, + =0 {nonexistent} + one {#st} + two {#nd} + few {#rd} + other {#th} + } notification is the most recent one."; + + yield return + new object[] + { + Case1, + new Dictionary { { "gender", "male" }, { "name", "Jeff" } }, + "He - {Jeff} - said: You're pretty cool!" + }; + yield return + new object[] + { + Case2, + new Dictionary { { "gender", "male" }, { "name", "Jeff" }, { "count", 0 } }, + "He - {Jeff} - said: You have no notifications. Have a nice day!" + }; + yield return + new object[] + { + Case2, + new Dictionary + { { "gender", "female" }, { "name", "Amanda" }, { "count", 1 } }, + "She - {Amanda} - said: You have just one notification. Have a nice day!" + }; + yield return + new object[] + { + Case2, + new Dictionary { { "gender", "uni" }, { "count", 42 } }, + "They said: You have a universal amount of notifications. Have a nice day!" + }; + yield return + new object[] + { + Case3, + new Dictionary { { "count", 5 } }, + "You have 5 notifications. Have a nice day!" + }; + yield return + new object[] + { + Case4, + new Dictionary { { "count", 5 }, { "gender", "male" } }, + "He said: You have 5 notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary { { "count", 5 }, { "gender", "male" }, { "genitals", 0 } }, + "He (who has no testicles) said: You have 5 notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary { { "count", 5 }, { "gender", "female" }, { "genitals", 0 } }, + "She (who has no boobies) said: You have 5 notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary { { "count", 0 }, { "gender", "female" }, { "genitals", 1 } }, + "She (who has just one boob) said: You have no notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary { { "count", 0 }, { "gender", "female" }, { "genitals", 2 } }, + "She (who has a pair of lovelies) said: You have no notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary { { "count", 0 }, { "gender", "female" }, { "genitals", 102 } }, + "She (who has the freakish amount of 102 boobies) said: You have no notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary + { { "count", 42 }, { "gender", "female" }, { "genitals", 102 } }, + "She (who has the freakish amount of 102 boobies) said: You have a universal amount of notifications. Have a nice day!" + }; + yield return + new object[] + { + Case5, + new Dictionary { { "count", 1 }, { "gender", "male" }, { "genitals", 102 } }, + "He (who has the insane amount of 102 testicles) said: You have just one notification. Have a nice day!" + }; - #endregion - - #region Public Methods and Operators - - /// - /// The format message. - /// - /// - /// The source. - /// - /// - /// The args. - /// - /// - /// The expected. - /// - [Theory] - [MemberData(nameof(Tests))] - public void FormatMessage(string source, Dictionary args, string expected) - { - var subject = new MessageFormatter(false); - - // Warmup - subject.FormatMessage(source, args); - Benchmark.Start("Formatting", this.outputHelper); - string result = subject.FormatMessage(source, args); - Benchmark.End(this.outputHelper); - Assert.Equal(expected, result); - this.outputHelper.WriteLine(result); + // Case from https://github.com/jeffijoe/messageformat.net/issues/2 + yield return + new object[] + { + "{nbrAttachments, plural, zero {} one {{nbrAttachmentsFmt} attachment} other {{nbrAttachmentsFmt} attachments}}", + new Dictionary { { "nbrAttachments", 0 }, { "nbrAttachmentsFmt", "wut" } }, + string.Empty + }; + + // Following 2 cases from https://github.com/jeffijoe/messageformat.net/issues/4 + yield return + new object[] + { + "{maybeCount}", + new Dictionary { { "maybeCount", null } }, + string.Empty + }; + yield return + new object[] + { + "{maybeCount}", + new Dictionary { { "maybeCount", (int?)2 } }, + "2" + }; + yield return + new object[] + { + Case6, + new Dictionary { { "count", 0 } }, + "You didn't add this to your profile." + }; + yield return + new object[] + { + Case6, + new Dictionary { { "count", 1 } }, + "You added this to your profile." + }; + yield return + new object[] + { + Case6, + new Dictionary { { "count", 2 } }, + "You and one other person added this to their profile." + }; + yield return + new object[] + { + Case6, + new Dictionary { { "count", 3 } }, + "You and 2 others added this to their profiles." + }; + yield return + new object[] + { + Case7, + new Dictionary { { "count", 0 } }, + "Your nonexistent notification is the most recent one." + }; + yield return + new object[] + { + Case7, + new Dictionary { { "count", 2 } }, + "Your 2nd notification is the most recent one." + }; + yield return + new object[] + { + "{ count, plural, one {1 thing} other {# things} }", + new Dictionary { { "count", 2 } }, + "2 things" + }; } + } - /// - /// The format message_debug. - /// - [Theory] - [MemberData(nameof(EscapingTests))] - public void FormatMessage_escaping(string source, Dictionary args, string expected) - { - var subject = new MessageFormatter(false); + #endregion - string result = subject.FormatMessage(source, args); - Assert.Equal(expected, result); - } + #region Public Methods and Operators + + /// + /// The format message. + /// + /// + /// The source. + /// + /// + /// The args. + /// + /// + /// The expected. + /// + [Theory] + [MemberData(nameof(Tests))] + public void FormatMessage(string source, Dictionary args, string expected) + { + var subject = new MessageFormatter(useCache: false); - /// - /// The format message_debug. - /// - [Fact] - public void FormatMessage_debug() + // Historically these tests relied on a default English pluralizer that mapped + // 0 to "zero"; adding that back in manually to ensure we maintain test coverage + // for multiple forms. + subject.CardinalPluralizers!.Add("en", number => number switch { - const string Source = @"{gender, select, + 0 => "zero", + 1 => "one", + _ => "other" + }); + + // Warmup + subject.FormatMessage(source, args, En); + Benchmark.Start("Formatting", this.outputHelper); + string result = subject.FormatMessage(source, args, En); + Benchmark.End(this.outputHelper); + Assert.Equal(expected, result); + this.outputHelper.WriteLine(result); + } + + /// + /// The format message_debug. + /// + [Theory] + [MemberData(nameof(EscapingTests))] + public void FormatMessage_escaping(string source, Dictionary args, string expected) + { + var subject = new MessageFormatter(false); + + string result = subject.FormatMessage(source, args); + Assert.Equal(expected, result); + } + + /// + /// The format message_debug. + /// + [Fact] + public void FormatMessage_debug() + { + const string Source = @"{gender, select, male {He} female {She} other {They} - } said: You have {count, plural, + } said: You have {count, plural, zero {no notifications} one {just one notification} =42 {a universal amount of notifications} other {# notifications} }. Have a nice day!"; - const string Expected = "He said: You have 5 notifications. Have a nice day!"; - var args = new Dictionary { { "gender", "male" }, { "count", 5 } }; - var subject = new MessageFormatter(false); + const string Expected = "He said: You have 5 notifications. Have a nice day!"; + var args = new Dictionary { { "gender", "male" }, { "count", 5 } }; + var subject = new MessageFormatter(false); - string result = subject.FormatMessage(Source, args); - Assert.Equal(Expected, result); - } + string result = subject.FormatMessage(Source, args); + Assert.Equal(Expected, result); + } - /// - /// The format message_lets_non_ascii_characters_right_through. - /// - [Fact] - public void FormatMessage_lets_non_ascii_characters_right_through() - { - const string Input = "中test中国话不用彁字。"; - var subject = new MessageFormatter(false); - var actual = subject.FormatMessage(Input, new Dictionary()); - Assert.Equal(Input, actual); - } + /// + /// The format message_lets_non_ascii_characters_right_through. + /// + [Fact] + public void FormatMessage_lets_non_ascii_characters_right_through() + { + const string Input = "中test中国话不用彁字。"; + var subject = new MessageFormatter(false); + var actual = subject.FormatMessage(Input, new Dictionary()); + Assert.Equal(Input, actual); + } - /// - /// The format message_nesting_with_brace_escaping. - /// - [Fact] - public void FormatMessage_nesting_with_brace_escaping() - { - var subject = new MessageFormatter(false); - const string Pattern = @"{s1, select, - 1 {{s2, select, + /// + /// The format message_nesting_with_brace_escaping. + /// + [Fact] + public void FormatMessage_nesting_with_brace_escaping() + { + var subject = new MessageFormatter(false); + const string Pattern = @"{s1, select, + 1 {{s2, select, 2 {'{'} }} }"; - var actual = subject.FormatMessage(Pattern, new { s1 = 1, s2 = 2 }); - this.outputHelper.WriteLine(actual); - Assert.Equal("{", actual); - } + var actual = subject.FormatMessage(Pattern, new { s1 = 1, s2 = 2 }); + this.outputHelper.WriteLine(actual); + Assert.Equal("{", actual); + } - /// - /// The format message_with_reflection_overload. - /// - [Fact] - public void FormatMessage_with_reflection_overload() - { - var subject = new MessageFormatter(false); - const string Pattern = "You have {UnreadCount, plural, " - + "zero {no unread messages}" - + "one {just one unread message}" + "other {# unread messages}" + "} today."; - var actual = subject.FormatMessage(Pattern, new { UnreadCount = 0 }); - Assert.Equal("You have no unread messages today.", actual); - - // The absence of UnreadCount should make it throw. - var ex = Assert.Throws(() => subject.FormatMessage(Pattern, new { })); - Assert.Equal("UnreadCount", ex.MissingVariable); - - actual = subject.FormatMessage(Pattern, new { UnreadCount = 1 }); - Assert.Equal("You have just one unread message today.", actual); - actual = subject.FormatMessage(Pattern, new { UnreadCount = 2 }); - Assert.Equal("You have 2 unread messages today.", actual); - - actual = subject.FormatMessage(Pattern, new { UnreadCount = 3 }); - Assert.Equal("You have 3 unread messages today.", actual); - } + /// + /// The format message_with_reflection_overload. + /// + [Fact] + public void FormatMessage_with_reflection_overload() + { + var subject = new MessageFormatter(false, culture: EnUs); + const string Pattern = "You have {UnreadCount, plural, " + + "=0 {no unread messages}" + + "one {just one unread message}" + "other {# unread messages}" + "} today."; + var actual = subject.FormatMessage(Pattern, new { UnreadCount = 0 }); + Assert.Equal("You have no unread messages today.", actual); + + // The absence of UnreadCount should make it throw. + var ex = Assert.Throws(() => subject.FormatMessage(Pattern, new { })); + Assert.Equal("UnreadCount", ex.MissingVariable); + + actual = subject.FormatMessage(Pattern, new { UnreadCount = 1 }); + Assert.Equal("You have just one unread message today.", actual); + actual = subject.FormatMessage(Pattern, new { UnreadCount = 2 }); + Assert.Equal("You have 2 unread messages today.", actual); + + actual = subject.FormatMessage(Pattern, new { UnreadCount = 3 }); + Assert.Equal("You have 3 unread messages today.", actual); + } - /// - /// The read me_test_to_make_sure_ i_dont_look_like_a_fool. - /// - [Fact] - public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() + /// + /// The read me_test_to_make_sure_ i_dont_look_like_a_fool. + /// + [Fact, UseCulture("en")] + public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() + { { - { - var mf = new MessageFormatter(false); - const string Str = @"You have {notifications, plural, - zero {no notifications} + var mf = new MessageFormatter(false); + const string Str = @"You have {notifications, plural, + =0 {no notifications} one {one notification} =42 {a universal amount of notifications} other {# notifications} }. Have a nice day, {name}!"; - var formatted = mf.FormatMessage( - Str, - new Dictionary { { "notifications", 4 }, { "name", "Jeff" } }); - Assert.Equal("You have 4 notifications. Have a nice day, Jeff!", formatted); - } + var formatted = mf.FormatMessage( + Str, + new Dictionary { { "notifications", 4 }, { "name", "Jeff" } }); + Assert.Equal("You have 4 notifications. Have a nice day, Jeff!", formatted); + } - { - var mf = new MessageFormatter(false); - const string Str = @"You {NUM_ADDS, plural, offset:1 - =0{didnt add this to your profile} - zero{added this to your profile} + { + var mf = new MessageFormatter(false); + const string Str = @"You {NUM_ADDS, plural, offset:1 + =0{didnt add this to your profile} + =1{added this to your profile} one{and one other person added this to their profile} other{and # others added this to their profiles} }."; - var formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 0 } }); - Assert.Equal("You didnt add this to your profile.", formatted); + var formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 0 } }); + Assert.Equal("You didnt add this to your profile.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 1 } }); - Assert.Equal("You added this to your profile.", formatted); + formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 1 } }); + Assert.Equal("You added this to your profile.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 2 } }); - Assert.Equal("You and one other person added this to their profile.", formatted); + formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 2 } }); + Assert.Equal("You and one other person added this to their profile.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 3 } }); - Assert.Equal("You and 2 others added this to their profiles.", formatted); - } + formatted = mf.FormatMessage(Str, new Dictionary { { "NUM_ADDS", 3 } }); + Assert.Equal("You and 2 others added this to their profiles.", formatted); + } - { - var mf = new MessageFormatter(false); - const string Str = @"{GENDER, select, + { + var mf = new MessageFormatter(false); + const string Str = @"{GENDER, select, male {He} female {She} other {They} @@ -514,103 +572,155 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() one {1 category} other {# categories} }."; - var formatted = mf.FormatMessage( - Str, - new Dictionary - { - { "GENDER", "male" }, - { "NUM_RESULTS", 1 }, - { "NUM_CATEGORIES", 2 } - }); - Assert.Equal("He found 1 result in 2 categories.", formatted); - - formatted = mf.FormatMessage( - Str, - new Dictionary - { - { "GENDER", "male" }, - { "NUM_RESULTS", 1 }, - { "NUM_CATEGORIES", 1 } - }); - Assert.Equal("He found 1 result in 1 category.", formatted); - - formatted = mf.FormatMessage( - Str, - new Dictionary - { - { "GENDER", "female" }, - { "NUM_RESULTS", 2 }, - { "NUM_CATEGORIES", 1 } - }); - Assert.Equal("She found 2 results in 1 category.", formatted); - } + var formatted = mf.FormatMessage( + Str, + new Dictionary + { + { "GENDER", "male" }, + { "NUM_RESULTS", 1 }, + { "NUM_CATEGORIES", 2 } + }); + Assert.Equal("He found 1 result in 2 categories.", formatted); + + formatted = mf.FormatMessage( + Str, + new Dictionary + { + { "GENDER", "male" }, + { "NUM_RESULTS", 1 }, + { "NUM_CATEGORIES", 1 } + }); + Assert.Equal("He found 1 result in 1 category.", formatted); + + formatted = mf.FormatMessage( + Str, + new Dictionary + { + { "GENDER", "female" }, + { "NUM_RESULTS", 2 }, + { "NUM_CATEGORIES", 1 } + }); + Assert.Equal("She found 2 results in 1 category.", formatted); + } - { - var mf = new MessageFormatter(false); - const string Str = @"Your {NUM, plural, one{message} other{messages}} go here."; - var formatted = mf.FormatMessage(Str, new Dictionary { { "NUM", 1 } }); - Assert.Equal("Your message go here.", formatted); + { + var mf = new MessageFormatter(false); + const string Str = @"Your {NUM, plural, one{message} other{messages}} go here."; + var formatted = mf.FormatMessage(Str, new Dictionary { { "NUM", 1 } }); + Assert.Equal("Your message go here.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "NUM", 3 } }); - Assert.Equal("Your messages go here.", formatted); - } + formatted = mf.FormatMessage(Str, new Dictionary { { "NUM", 3 } }); + Assert.Equal("Your messages go here.", formatted); + } - { - var mf = new MessageFormatter(false); - const string Str = @"His name is {LAST_NAME}... {FIRST_NAME} {LAST_NAME}"; - var formatted = mf.FormatMessage( - Str, - new Dictionary { { "FIRST_NAME", "James" }, { "LAST_NAME", "Bond" } }); - Assert.Equal("His name is Bond... James Bond", formatted); - } + { + var mf = new MessageFormatter(false); + const string Str = @"You are the {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} person in line."; + var formatted = mf.FormatMessage(Str, new Dictionary { { "position", 23 } }); + Assert.Equal("You are the 23rd person in line.", formatted); - { - var mf = new MessageFormatter(false); - const string Str = @"{GENDER, select, male{He} female{She} other{They}} liked this."; - var formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", "male" } }); - Assert.Equal("He liked this.", formatted); + formatted = mf.FormatMessage(Str, new Dictionary { { "position", 1 } }); + Assert.Equal("You are the 1st person in line.", formatted); + } + + { + var mf = new MessageFormatter(false); + const string Str = @"His name is {LAST_NAME}... {FIRST_NAME} {LAST_NAME}"; + var formatted = mf.FormatMessage( + Str, + new Dictionary { { "FIRST_NAME", "James" }, { "LAST_NAME", "Bond" } }); + Assert.Equal("His name is Bond... James Bond", formatted); + } + + { + var mf = new MessageFormatter(false); + const string Str = @"{GENDER, select, male{He} female{She} other{They}} liked this."; + var formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", "male" } }); + Assert.Equal("He liked this.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", "female" } }); - Assert.Equal("She liked this.", formatted); + formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", "female" } }); + Assert.Equal("She liked this.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", "somethingelse" } }); - Assert.Equal("They liked this.", formatted); + formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", "somethingelse" } }); + Assert.Equal("They liked this.", formatted); - formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", null } }); - Assert.Equal("They liked this.", formatted); - } + formatted = mf.FormatMessage(Str, new Dictionary { { "GENDER", null } }); + Assert.Equal("They liked this.", formatted); + } + { + var mf = new MessageFormatter(useCache: true); + mf.CardinalPluralizers!["en"] = n => { - var mf = new MessageFormatter(true, "en"); - mf.Pluralizers["en"] = n => { - // ´n´ is the number being pluralized. - // ReSharper disable once CompareOfFloatsByEqualityOperator - if (n == 0) - { - return "zero"; - } - - if (n == 1) - { - return "one"; - } - - if (n > 1000) - { - return "thatsalot"; - } - - return "other"; - }; - - var actual = - mf.FormatMessage( - "You have {number, plural, thatsalot {a shitload of notifications} other {# notifications}}", - new Dictionary { { "number", 1001 } }); - Assert.Equal("You have a shitload of notifications", actual); - } + // ´n´ is the number being pluralized. + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (n == 0) + { + return "zero"; + } + + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (n == 1) + { + return "one"; + } + + if (n > 1000) + { + return "thatsalot"; + } + + return "other"; + }; + + var actual = + mf.FormatMessage( + "You have {number, plural, thatsalot {a shitload of notifications} other {# notifications}}", + new Dictionary { { "number", 1001 } }, + En); + Assert.Equal("You have a shitload of notifications", actual); } + } + + [Fact] + public void FormatMessage_uses_constructor_culture_as_default() + { + var mf = new MessageFormatter(culture: DaDk); + + // Should use da-DK formatting without specifying culture on FormatMessage. + var result = mf.FormatMessage("{value, number}", new Dictionary { { "value", 1234.5m } }); + Assert.Equal("1.234,5", result); + } + + [Fact] + public void FormatMessage_with_culture_override() + { + var mf = new MessageFormatter(culture: EnUs); + + // Without override, uses the constructor culture (en-US). + var resultUs = mf.FormatMessage("{value, number}", new Dictionary { { "value", 1234.5m } }); + Assert.Equal("1,234.5", resultUs); + + // With override, uses da-DK formatting (period as thousands separator, comma as decimal). + var resultDk = mf.FormatMessage( + "{value, number}", + new Dictionary { { "value", 1234.5m } }, + DaDk); + Assert.Equal("1.234,5", resultDk); + } - #endregion + [Fact] + public void FormatMessage_culture_override_propagates_to_nested_formatting() + { + var mf = new MessageFormatter(); + + // The culture override should propagate through nested formatting (e.g. select -> number). + var result = mf.FormatMessage( + "{gender, select, male {He earned {amount, number}} other {They earned {amount, number}}}", + new Dictionary { { "gender", "male" }, { "amount", 1234.5m } }, + DaDk); + Assert.Equal("He earned 1.234,5", result); } -} \ No newline at end of file + + #endregion +} diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterIssues.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterIssues.cs index 7d3d40e..acb2cd1 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterIssues.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterIssues.cs @@ -1,56 +1,94 @@ -// MessageFormat for .NET +// MessageFormat for .NET // - MessageFormatter_full_integration_tests.cs -// +// // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. using System.Collections.Generic; - -using Jeffijoe.MessageFormat.Formatting; -using Jeffijoe.MessageFormat.Tests.TestHelpers; - +using System.Globalization; using Xunit; -using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests +namespace Jeffijoe.MessageFormat.Tests; + +/// +/// Issue cases. +/// +public class MessageFormatterIssues { - /// - /// Issue cases. - /// - public class MessageFormatterIssues + private static readonly CultureInfo En = CultureInfo.GetCultureInfo("en"); + + [Fact] + public void Issue13_Bad_escaping_on_pound_symbol() { - #region Fields + string plural = @"{num_guests, plural, offset:1, other {# {host} invites # people to their party.}}"; + string broken = @"{num_guests, plural, offset:1, other {{host} invites # people to their party.}}"; - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; + var mf = new MessageFormatter(); + var vars = new { num_guests = "5", host = "Mary" }; + Assert.Equal("Mary invites 4 people to their party.", mf.FormatMessage(broken, vars)); + Assert.Equal("4 Mary invites 4 people to their party.", mf.FormatMessage(plural, vars)); + } - #endregion + [Fact] + public void Issue27_WhiteSpace_in_identifiers_is_ignored() + { + var subject = new MessageFormatter(false); + var result = subject.FormatMessage("{ count, plural , one {1 thing} other {# things} }", new + { + count = 2 + }); - #region Constructors and Destructors + Assert.Equal("2 things", result); + } - /// - /// Ctor. - /// - /// - public MessageFormatterIssues(ITestOutputHelper outputHelper) + [Fact] + public void Issue31_IDictionary_interface_support() + { + var subject = new MessageFormatter(); + + IDictionary idict = new Dictionary { - this.outputHelper = outputHelper; - } + ["string"] = "value" + }; - #endregion + IDictionary idictNullable = new Dictionary + { + ["string"] = "value" + }; - [Fact] - public void Issue13() + Assert.Equal("value", subject.FormatMessage("{string}", idict, En)); + Assert.Equal("value", subject.FormatMessage("{string}", idictNullable!, En)); + } + + [Fact] + public void Issue34_Newlines_are_stripped() + { + var subject = new MessageFormatter(); + + const string Expected = "Single text which will not change.\nSummary:\nAccepted\nData:\n-X\n-Y\n-Z"; + + var result = subject.FormatMessage( + "Single text which will not change.\nSummary:{acceptedData, select, NONE {} other {\nAccepted\nData:{acceptedData}}}", + new + { + acceptedData = "\n-X\n-Y\n-Z" + }, En); + Assert.Equal(Expected, result); + } + + [Fact] + public void Issue45_Url_should_not_be_parsed_as_extension() + { + var subject = new MessageFormatter(); + + IDictionary dict = new Dictionary { - string plural = @"{num_guests, plural, offset:1, other {# {host} invites # people to their party.}}"; - string broken = @"{num_guests, plural, offset:1, other {{host} invites # people to their party.}}"; - - var mf = new MessageFormatter(); - var vars = new { num_guests = "5", host = "Mary" }; - Assert.Equal("Mary invites 4 people to their party.", mf.FormatMessage(broken, vars)); - Assert.Equal("4 Mary invites 4 people to their party.", mf.FormatMessage(plural, vars)); - } + ["cond"] = "foo" + }; + + var result = subject.FormatMessage( + "{cond, select, foo{https://www.google.com/} other{https://www.bing.com/}}", + dict, En); + Assert.Equal("https://www.google.com/", result); } -} \ No newline at end of file +} diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterStringExtensionTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterStringExtensionTests.cs index 6271c51..9aa57c3 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterStringExtensionTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterStringExtensionTests.cs @@ -4,41 +4,43 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. +using System.Globalization; using System.Threading.Tasks; using Xunit; -namespace Jeffijoe.MessageFormat.Tests +namespace Jeffijoe.MessageFormat.Tests; + +/// +/// The message formatter string extension tests. +/// +public class MessageFormatterStringExtensionTests { + #region Public Methods and Operators + /// - /// The message formatter string extension tests. + /// The format message_with_multiple_tasks. /// - public class MessageFormatterStringExtensionTests + /// + /// The . + /// + [Fact] + public async Task FormatMessage_with_multiple_tasks() { - #region Public Methods and Operators - - /// - /// The format message_with_multiple_tasks. - /// - /// - /// The . - /// - [Fact] - public async Task FormatMessage_with_multiple_tasks() - { - var pattern = "Copying {fileCount, plural, one {one file} other{# files}}."; - - // 2 with the same message to test there are no issues with caching with multiple threads. - var t1 = Task.Run(() => MessageFormatter.Format(pattern, new { fileCount = 1 })); - var t2 = Task.Run(() => MessageFormatter.Format(pattern, new { fileCount = 1 })); - var t3 = Task.Run(() => MessageFormatter.Format(pattern, new { fileCount = 5 })); - await Task.WhenAll(t1, t2); - - Assert.Equal("Copying one file.", t1.Result); - Assert.Equal("Copying one file.", t2.Result); - Assert.Equal("Copying 5 files.", t3.Result); - } - - #endregion + const string Pattern = "Copying {fileCount, plural, one {one file} other{# files}}."; + + var en = CultureInfo.GetCultureInfo("en"); + + // 2 with the same message to test there are no issues with caching with multiple threads. + var t1 = Task.Run(() => MessageFormatter.Format(Pattern, new { fileCount = 1 }, en)); + var t2 = Task.Run(() => MessageFormatter.Format(Pattern, new { fileCount = 1 }, en)); + var t3 = Task.Run(() => MessageFormatter.Format(Pattern, new { fileCount = 5 }, en)); + await Task.WhenAll(t1, t2, t3); + + Assert.Equal("Copying one file.", await t1); + Assert.Equal("Copying one file.", await t2); + Assert.Equal("Copying 5 files.", await t3); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterTests.cs index 594e2b1..270fa97 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterTests.cs @@ -5,176 +5,118 @@ // Copyright (C) Jeff Hansen 2015. All rights reserved. using System.Collections.Generic; -using System.Linq; +using System.Globalization; using System.Text; using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Parsing; -using Moq; - using Xunit; -namespace Jeffijoe.MessageFormat.Tests +namespace Jeffijoe.MessageFormat.Tests; + +/// +/// The message formatter tests. +/// +public class MessageFormatterTests { + #region Public Methods and Operators + /// - /// The message formatter tests. + /// The format message. /// - public class MessageFormatterTests + [Fact] + public void FormatMessage() { - #region Fields - - /// - /// The collection mock. - /// - private readonly Mock collectionMock; - - /// - /// The formatter mock 1. - /// - private readonly Mock formatterMock1; - - /// - /// The formatter mock 2. - /// - private readonly Mock formatterMock2; - - /// - /// The library mock. - /// - private readonly Mock libraryMock; - - /// - /// The message formatter. - /// - private readonly MessageFormatter subject; - - /// - /// The pattern parser mock. - /// - private readonly Mock patternParserMock; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - public MessageFormatterTests() - { - this.patternParserMock = new Mock(); - this.libraryMock = new Mock(); - this.collectionMock = new Mock(); - this.formatterMock1 = new Mock(); - this.formatterMock2 = new Mock(); - this.subject = new MessageFormatter(this.patternParserMock.Object, this.libraryMock.Object, false); - } + const string Pattern = "{name} has {messages, plural, other {# messages}}."; + const string Expected = "Jeff has 123 messages."; + IReadOnlyDictionary args = new Dictionary { { "name", "Jeff" }, { "messages", 123} }; + + var actual = MessageFormatter.Format(Pattern, args); + + Assert.Equal(Expected, actual); + } - #endregion + /// + /// The unescape literals. + /// + /// + /// The source. + /// + /// + /// The expected. + /// + [Theory] + [InlineData(@"Hello '{buddy}', how are you '{doing}'?", "Hello {buddy}, how are you {doing}?")] + [InlineData(@"Hello ''{buddy}'', how are you '{doing}'?", @"Hello '{buddy}', how are you {doing}?")] + [InlineData(@"{''}", @"{'}")] + public void UnescapeLiterals(string source, string expected) + { + var actual = MessageFormatter.UnescapeLiterals(new StringBuilder(source)); + Assert.Equal(expected, actual); + } - #region Public Methods and Operators + /// + /// Verifies that format message throws when variables are missing and the formatter requires it to exist. + /// + [Fact] + public void VerifyFormatMessageThrowsWhenVariablesAreMissingAndTheFormatterRequiresItToExist() + { + const string Pattern = "{name}"; - /// - /// The format message. - /// - [Fact] - public void FormatMessage() - { - const string Pattern = "{name} has {messages, plural, 123}."; - const string Expected = "Jeff has 123 messages."; - var args = new Dictionary { { "name", "Jeff" }, { "messages", 1 } }; - var requests = new[] - { - new FormatterRequest( - new Literal(0, 5, 1, 7, new StringBuilder("name")), - "name", - null, - null), - new FormatterRequest( - new Literal(11, 33, 1, 7, new StringBuilder("messages, plural, 123")), - "messages", - "plural", - " 123") - }; - - this.formatterMock1.Setup(x => x.Format("en", requests[0], args, "Jeff", this.subject)).Returns("Jeff"); - this.formatterMock2.Setup(x => x.Format("en", requests[1], args, 1, this.subject)).Returns("123 messages"); - this.collectionMock.Setup(x => x.GetEnumerator()).Returns(requests.AsEnumerable().GetEnumerator()); - this.libraryMock.Setup(x => x.GetFormatter(requests[0])).Returns(this.formatterMock1.Object); - this.libraryMock.Setup(x => x.GetFormatter(requests[1])).Returns(this.formatterMock2.Object); - this.patternParserMock.Setup(x => x.Parse(It.IsAny())).Returns(this.collectionMock.Object); - - // First request, and "name" is 4 chars. - this.collectionMock.Setup(x => x.ShiftIndices(0, 4)).Callback( - - // The '- 2' is also done in the used implementation. - (int index, int length) => requests[1].SourceLiteral.ShiftIndices(length - 2, requests[0].SourceLiteral)); - - var actual = this.subject.FormatMessage(Pattern, args); - this.collectionMock.Verify(x => x.ShiftIndices(0, 4), Times.Once); - this.libraryMock.VerifyAll(); - this.formatterMock1.VerifyAll(); - this.formatterMock2.VerifyAll(); - this.patternParserMock.VerifyAll(); - Assert.Equal(Expected, actual); - } + // Note the missing "name" variable. + var args = new Dictionary { { "messages", 1 } }; - /// - /// The unescape literals. - /// - /// - /// The source. - /// - /// - /// The expected. - /// - [Theory] - [InlineData(@"Hello '{buddy}', how are you '{doing}'?", "Hello {buddy}, how are you {doing}?")] - [InlineData(@"Hello ''{buddy}'', how are you '{doing}'?", @"Hello '{buddy}', how are you {doing}?")] - public void UnescapeLiterals(string source, string expected) + var subject = new MessageFormatter(); + + var ex = Assert.Throws(() => subject.FormatMessage(Pattern, args)); + Assert.Equal("name", ex.MissingVariable); + } + + /// + /// Verifies that format message allows non-existent variables when formatter allows it. + /// + [Fact] + public void VerifyFormatMessageAllowsNonExistentVariablesWhenFormatterAllowsIt() + { + const string Pattern = "{name, fake}"; + + // Note the missing "name" variable. + var args = new Dictionary (); + + var library = new FormatterLibrary(); + library.Add(new TestFormatter(variableMustExist: false, formatterName: "fake")); + var subject = new MessageFormatter(new PatternParser(), library, useCache: false); + + var actual = subject.FormatMessage(Pattern, args); + + Assert.Equal("formatted", actual); + } + + #endregion + + #region Fakes + + private class TestFormatter : IFormatter + { + private readonly string formatterName; + + public TestFormatter(bool variableMustExist, string formatterName) { - var actual = this.subject.UnescapeLiterals(new StringBuilder(source)).ToString(); - Assert.Equal(expected, actual); + this.VariableMustExist = variableMustExist; + this.formatterName = formatterName; } + + public bool VariableMustExist { get; } - /// - /// Verifies that format message throws when variables are missing. - /// - [Fact] - public void VerifyFormatMessageThrowsWhenVariablesAreMissing() + public bool CanFormat(FormatterRequest request) => request.FormatterName == this.formatterName; + + public string Format(CultureInfo culture, FormatterRequest request, IReadOnlyDictionary args, object? value, + IMessageFormatter messageFormatter) { - const string Pattern = "{name} has {messages, plural, 123}."; - - // Note the missing "name" variable. - var args = new Dictionary { { "messages", 1 } }; - var requests = new[] - { - new FormatterRequest( - new Literal(0, 5, 1, 7, new StringBuilder("name")), - "name", - null, - null), - new FormatterRequest( - new Literal(11, 33, 1, 7, new StringBuilder("messages, plural, 123")), - "messages", - "plural", - " 123") - }; - - this.collectionMock.Setup(x => x.GetEnumerator()).Returns(() => requests.AsEnumerable().GetEnumerator()); - this.patternParserMock.Setup(x => x.Parse(It.IsAny())).Returns(this.collectionMock.Object); - - // First request, and "name" is 4 chars. - this.collectionMock.Setup(x => x.ShiftIndices(0, 4)).Callback( - - // The '- 2' is also done in the used implementation. - (int index, int length) => requests[1].SourceLiteral.ShiftIndices(length - 2, requests[0].SourceLiteral)); - - var ex = Assert.Throws(() => this.subject.FormatMessage(Pattern, args)); - Assert.Equal("name", ex.MissingVariable); + return "formatted"; } - - #endregion } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterUsingRealParserTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterUsingRealParserTests.cs index d5ca35a..5d810fe 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterUsingRealParserTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterUsingRealParserTests.cs @@ -10,55 +10,53 @@ using Jeffijoe.MessageFormat.Parsing; using Jeffijoe.MessageFormat.Tests.TestHelpers; -using Moq; - using Xunit; using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests +namespace Jeffijoe.MessageFormat.Tests; + +/// +/// The message formatter_using_real_parser_ tests. +/// +public class MessageFormatterUsingRealParserTests { + #region Fields + /// - /// The message formatter_using_real_parser_ tests. + /// The output helper. /// - public class MessageFormatterUsingRealParserTests - { - #region Fields - - /// - /// The output helper. - /// - private ITestOutputHelper outputHelper; + private ITestOutputHelper outputHelper; - #endregion + #endregion - #region Constructors and Destructors + #region Constructors and Destructors - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public MessageFormatterUsingRealParserTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public MessageFormatterUsingRealParserTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } - #endregion + #endregion - #region Public Methods and Operators + #region Public Methods and Operators - /// - /// The format message_using_real_parser_and_library_mock. - /// - /// - /// The source. - /// - /// - /// The expected. - /// - [Theory] - [InlineData(@"Hi, I'm {name}, and it's still {name, plural, whatever + /// + /// The format message_using_real_parser_and_library_mock. + /// + /// + /// The source. + /// + /// + /// The expected. + /// + [Theory] + [InlineData(@"Hi, I'm {name}, and it's still {name, fake, whatever i do what i want @@ -71,37 +69,34 @@ whatchu gonna do? whatchu gonna do when dey come for youu? }, ok?", "Hi, I'm Jeff, and it's still Jeff, ok?")] - public void FormatMessage_using_real_parser_and_library_mock(string source, string expected) + public void FormatMessage_using_real_parser_and_library_mock(string source, string expected) + { + var library = new FormatterLibrary(); + var dummyFormatter = new FakeFormatter(canFormat:true, formatResult: "Jeff"); + library.Add(dummyFormatter); + var subject = new MessageFormatter( + new PatternParser(new LiteralParser()), + library, + false); + + var args = new Dictionary(); + args.Add("name", "Jeff"); + + // Warm up + Benchmark.Start("Warm-up", this.outputHelper); + subject.FormatMessage(source, args); + Benchmark.End(this.outputHelper); + + Benchmark.Start("Aaaand a few after warm-up", this.outputHelper); + for (int i = 0; i < 1000; i++) { - var mockLibary = new Mock(); - var dummyFormatter = new Mock(); - var subject = new MessageFormatter( - new PatternParser(new LiteralParser()), - mockLibary.Object, - false); - - var args = new Dictionary(); - args.Add("name", "Jeff"); - dummyFormatter.Setup(x => x.Format("en", It.IsAny(), args, "Jeff", subject)) - .Returns("Jeff"); - mockLibary.Setup(x => x.GetFormatter(It.IsAny())).Returns(dummyFormatter.Object); - - // Warm up - Benchmark.Start("Warm-up", this.outputHelper); subject.FormatMessage(source, args); - Benchmark.End(this.outputHelper); - - Benchmark.Start("Aaaand a few after warm-up", this.outputHelper); - for (int i = 0; i < 1000; i++) - { - subject.FormatMessage(source, args); - } - - Benchmark.End(this.outputHelper); - - Assert.Equal(expected, subject.FormatMessage(source, args)); } - #endregion + Benchmark.End(this.outputHelper); + + Assert.Equal(expected, subject.FormatMessage(source, args)); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs new file mode 100644 index 0000000..09f56bc --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using Jeffijoe.MessageFormat.Formatting; +using Jeffijoe.MessageFormat.Formatting.Formatters; +using Jeffijoe.MessageFormat.Parsing; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.MetadataGenerator; + +public class GeneratedPluralRulesTests +{ + [Theory] + [InlineData(0, "днів")] + [InlineData(1, "день")] + [InlineData(101, "день")] + [InlineData(102, "дні")] + [InlineData(105, "днів")] + public void Uk_PluralizerTests(double n, string expected) + { + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", n } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("one", "день"), + new KeyedBlock("few", "дні"), + new KeyedBlock("many", "днів"), + new KeyedBlock("other", "дня") + }, + new FormatterExtension[0]); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); + var actual = subject.Pluralize("uk", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(0, "дней")] + [InlineData(1, "день")] + [InlineData(101, "день")] + [InlineData(102, "дня")] + [InlineData(105, "дней")] + public void Ru_PluralizerTests(double n, string expected) + { + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", n } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("one", "день"), + new KeyedBlock("few", "дня"), + new KeyedBlock("many", "дней"), + new KeyedBlock("other", "дня") + }, + new FormatterExtension[0]); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); + var actual = subject.Pluralize("ru", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(0, "days")] + [InlineData(1, "day")] + [InlineData(101, "days")] + [InlineData(102, "days")] + [InlineData(105, "days")] + public void EnUS_Cardinal_PluralizerTests(double n, string expected) + { + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", n } }; + var arguments = + new ParsedArguments( + new[] + { + // Regression test to ensure 0 does not match 'zero' for English + new KeyedBlock("zero", "FAIL"), + new KeyedBlock("one", "day"), + new KeyedBlock("other", "days") + }, + new FormatterExtension[0]); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); + var actual = subject.Pluralize("en_US", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(0, "0th")] + [InlineData(1, "1st")] + [InlineData(2, "2nd")] + [InlineData(3, "3rd")] + [InlineData(4, "4th")] + [InlineData(9, "9th")] + [InlineData(11, "11th")] + [InlineData(21, "21st")] + public void EnUS_Ordinal_PluralizerTests(double n, string expected) + { + var subject = new PluralFormatter(); + var args = new Dictionary { { "test", n } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("one", "#st"), + new KeyedBlock("two", "#nd"), + new KeyedBlock("few", "#rd"), + new KeyedBlock("other", "#th"), + }, + new FormatterExtension[0]); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.OrdinalFunction, null); + var pluralized = subject.Pluralize("en-US", PluralRulesMetadata.TryGetOrdinalRuleByLocale, subject.OrdinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.ReplaceNumberLiterals(pluralized, n); + Assert.Equal(expected, actual); + } + + [Fact] + public void RootLocale_MatchesRules() + { + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale(PluralRulesMetadata.RootLocale, out _)); + Assert.True(PluralRulesMetadata.TryGetOrdinalRuleByLocale(PluralRulesMetadata.RootLocale, out _)); + } + + /// + /// Tests to confirm that separators normalize properly in the data, + /// and that language lookups are case insensitive. + /// + [Fact] + public void Fallback_PluralizerTests() + { + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("kok_Latn", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("pt-PT", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("pt-pt", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("PT_PT", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("pT", out _)); + + Assert.True(PluralRulesMetadata.TryGetOrdinalRuleByLocale("kok_Latn", out _)); + Assert.False(PluralRulesMetadata.TryGetOrdinalRuleByLocale("pt-PT", out _)); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs new file mode 100644 index 0000000..08bc95a --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs @@ -0,0 +1,275 @@ +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +using System; +using System.Xml; + +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.MetadataGenerator; + +public class ParserTests +{ + [Fact] + public void CanParseLocales() + { + var rules = ParseRules(@" + + + + + + +"); + + var rule = Assert.Single(rules.UniqueRules); + var expected = new[] + { + "am", "as", "bn", "doi", "fa", "gu", "hi", "kn", "pcm", "zu" + }; + var actual = rule.Locales; + Assert.Equal(actual, expected); + } + + [Fact] + public void OtherCountIsIgnored() + { + var rules = ParseRules(@" + + + + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + +"); + var rule = Assert.Single(rules.UniqueRules); + Assert.Empty(rule.Conditions); + } + + [Fact] + public void CanParseSingleCount_RuleDescription_WithoutRelations() + { + var rules = ParseRules(GenerateXmlWithRuleContent("@integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var expected = "@integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …"; + Assert.Equal(expected, condition.RuleDescription); + } + + [Fact] + public void CanParseSingleCount_VisibleDigitsNumber() + { + var rules = ParseRules( + GenerateXmlWithRuleContent(@"v = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var expected = new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }); + + AssertOperationEqual(expected, actual); + } + + [Fact] + public void CanParseSingleCount_IntegerDigits() + { + var rules = ParseRules( + GenerateXmlWithRuleContent(@"i = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var expected = new Operation(new VariableOperand(OperandSymbol.IntegerDigits), Relation.Equals, new[] { new NumberOperand(0) }); + + AssertOperationEqual(expected, actual); + } + + [Fact] + public void CanParseSingleCount_AbsoluteNumber() + { + var rules = ParseRules( + GenerateXmlWithRuleContent("n = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var expected = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] {new NumberOperand(1) }); + + AssertOperationEqual(expected, actual); + } + + [Theory] + [InlineData("n = 2 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", Relation.Equals)] + [InlineData("n != 2 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", Relation.NotEquals)] + public void CanParseVariousRelations(string ruleText, Relation expectedRelation) + { + var rules = ParseRules(GenerateXmlWithRuleContent(ruleText)); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var expected = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), expectedRelation, new[] { new NumberOperand(2) }); + + AssertOperationEqual(expected, actual); + } + + [Fact] + public void CanParseOrRules() + { + var rules = ParseRules(GenerateXmlWithRuleContent("n = 2 or n = 1 or n = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + + Assert.Equal(3, condition.OrConditions.Count); + + var actualFirst = Assert.Single(condition.OrConditions[0].AndConditions); + var expectedFirst = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(2) }); + AssertOperationEqual(expectedFirst, actualFirst); + + var actualSecond = Assert.Single(condition.OrConditions[1].AndConditions); + var expectedSecond = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(1) }); + AssertOperationEqual(expectedSecond, actualSecond); + + var actualThird = Assert.Single(condition.OrConditions[2].AndConditions); + var expectedThird = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(0) }); + AssertOperationEqual(expectedThird, actualThird); + } + + [Fact] + public void CanParseAndRules() + { + var rules = ParseRules(GenerateXmlWithRuleContent("n = 2 and n = 1 and n = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + + var orCondition = Assert.Single(condition.OrConditions); + Assert.Equal(3, orCondition.AndConditions.Count); + + var actualFirst = orCondition.AndConditions[0]; + var expectedFirst = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(2) }); + AssertOperationEqual(expectedFirst, actualFirst); + + var actualSecond = orCondition.AndConditions[1]; + var expectedSecond = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(1) }); + AssertOperationEqual(expectedSecond, actualSecond); + + var actualThird = orCondition.AndConditions[2]; + var expectedThird = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(0) }); + AssertOperationEqual(expectedThird, actualThird); + } + + [Fact] + public void CanParseModuloInLeftOperator() + { + var rules = ParseRules( + GenerateXmlWithRuleContent("n % 5 = 3 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var modulo = new ModuloOperand(OperandSymbol.AbsoluteValue, 5); + var expected = new Operation(modulo, Relation.Equals, new[] { new NumberOperand(3) }); + + AssertOperationEqual(expected, actual); + } + + [Fact] + public void CanParseRangeInRightOperator() + { + var rules = ParseRules( + GenerateXmlWithRuleContent("n = 3..5 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var range = new[] { new RangeOperand(3, 5) }; + var expected = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, range); + + AssertOperationEqual(expected, actual); + } + + [Fact] + public void CanParseCommaSeparatedInRightOperator() + { + var rules = ParseRules( + GenerateXmlWithRuleContent("n = 3,5,8, 10 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var range = new[] { new NumberOperand(3), new NumberOperand(5), new NumberOperand(8), new NumberOperand(10) }; + var expected = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, range); + + AssertOperationEqual(expected, actual); + } + + [Fact] + public void CanParseMixedCommaSeparatedAndRangeInRightOperator() + { + var rules = ParseRules( + GenerateXmlWithRuleContent("n = 3,5..7,12,15 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var range = new IRightOperand[] { new NumberOperand(3), new RangeOperand(5, 7), new NumberOperand(12), new NumberOperand(15) }; + var expected = new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, range); + + AssertOperationEqual(expected, actual); + } + + [Theory] + [InlineData('n', OperandSymbol.AbsoluteValue)] + [InlineData('i', OperandSymbol.IntegerDigits)] + [InlineData('v', OperandSymbol.VisibleFractionDigitNumber)] + [InlineData('w', OperandSymbol.VisibleFractionDigitNumberWithoutTrailingZeroes)] + [InlineData('f', OperandSymbol.VisibleFractionDigits)] + [InlineData('t', OperandSymbol.VisibleFractionDigitsWithoutTrailingZeroes)] + [InlineData('c', OperandSymbol.ExponentC)] + [InlineData('e', OperandSymbol.ExponentE)] + public void MapsVariable_ToCorrectOperator(char variable, OperandSymbol symbol) + { + var rules = ParseRules( + GenerateXmlWithRuleContent($"{variable} = 3")); + var rule = Assert.Single(rules.UniqueRules); + var condition = Assert.Single(rule.Conditions); + var orCondition = Assert.Single(condition.OrConditions); + var actual = Assert.Single(orCondition.AndConditions); + var right = new IRightOperand[] { new NumberOperand(3) }; + var expected = new Operation(new VariableOperand(symbol), Relation.Equals, right); + + AssertOperationEqual(expected, actual); + } + + private static string GenerateXmlWithRuleContent(string ruleText) + { + return $@" + + + + {ruleText} + + + +"; + } + + private static void AssertOperationEqual(Operation expected, Operation actual) + { + Assert.Equal(expected.OperandLeft, actual.OperandLeft); + Assert.Equal(expected.Relation, actual.Relation); + Assert.Equal(expected.OperandRight, actual.OperandRight); + } + + private static PluralRuleSet ParseRules(string xmlText) + { + var xml = new XmlDocument(); + xml.LoadXml(xmlText); + + var parser = new PluralParser(xml, Array.Empty()); + + return parser.Parse(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralContextTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralContextTests.cs new file mode 100644 index 0000000..c0986c5 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralContextTests.cs @@ -0,0 +1,108 @@ +using Jeffijoe.MessageFormat.Formatting.Formatters; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.MetadataGenerator; + +public class PluralContextTests +{ + [Theory] + [InlineData("-12312.213213", 12312.213213)] + [InlineData("12312.213213", 12312.213213)] + [InlineData("-12312", 12312)] + [InlineData("12312", 12312)] + [InlineData("0", 0)] + public void Parses_N(string s, double expectedN) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedN, ctx.N); + } + + [Theory] + [InlineData("-12312.213213", -12312)] + [InlineData("12312.213213", 12312)] + [InlineData("-12312", -12312)] + [InlineData("12312", 12312)] + [InlineData("0", 0)] + public void Parses_I(string s, double expectedI) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedI, ctx.I); + } + + [Theory] + [InlineData("-12312.213213", 6)] + [InlineData("12312.213213", 6)] + [InlineData("12312.2132130", 7)] + [InlineData("-12312", 0)] + [InlineData("12312", 0)] + [InlineData("0", 0)] + public void Parses_V(string s, double expectedV) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedV, ctx.V); + } + + [Theory] + [InlineData("-12312.213213", 6)] + [InlineData("12312.213213", 6)] + [InlineData("12312.2132130", 6)] + [InlineData("-12312", 0)] + [InlineData("12312", 0)] + [InlineData("0", 0)] + public void Parses_W(string s, double expectedW) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedW, ctx.W); + } + + [Theory] + [InlineData("-12312.213213", 213213)] + [InlineData("12312.213213", 213213)] + [InlineData("12312.2132130", 2132130)] + [InlineData("-12312", 0)] + [InlineData("12312", 0)] + [InlineData("0", 0)] + public void Parses_F(string s, double expectedF) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedF, ctx.F); + } + + [Theory] + [InlineData("-12312.213213", 213213)] + [InlineData("12312.213213", 213213)] + [InlineData("12312.2132130", 213213)] + [InlineData("-12312", 0)] + [InlineData("12312", 0)] + [InlineData("0", 0)] + public void Parses_T(string s, double expectedT) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedT, ctx.T); + } + + + /// + /// Exponents not supported yet + /// + [Theory] + [InlineData("-12312.213213", 0)] + [InlineData("12312.213213", 0)] + [InlineData("12312.2132130", 0)] + [InlineData("-12312", 0)] + [InlineData("12312", 0)] + [InlineData("0", 0)] + public void Parses_C_And_E(string s, double expectedC) + { + var ctx = new PluralContext(s); + + Assert.Equal(expectedC, ctx.C); + Assert.Equal(expectedC, ctx.E); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs new file mode 100644 index 0000000..7060ae2 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs @@ -0,0 +1,112 @@ +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; + +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.MetadataGenerator; + +public class PluralMetadataClassGeneratorTests +{ + [Fact] + public void CanGenerateClassFromRules() + { + var rules = new[] + { + new PluralRule(new[] {"root", "en", "uk"}, + new[] + { + new Condition("one", string.Empty, new [] + { + new OrCondition(new[] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] {new NumberOperand(3) }) + }) + }) + }), + new PluralRule(new[] {"root", "en", "pt_PT"}, + new[] + { + new Condition("many", string.Empty, new [] + { + new OrCondition(new[] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] {new NumberOperand(120) }) + }) + }) + }), + }; + + var ruleSet = new PluralRuleSet(); + ruleSet.Add("cardinal", rules[0]); + ruleSet.Add("ordinal", rules[1]); + + var generator = new PluralRulesMetadataGenerator(ruleSet); + + var actual = generator.GenerateClass(); + + var expected = @" +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +namespace Jeffijoe.MessageFormat.Formatting.Formatters +{ + internal static partial class PluralRulesMetadata + { + public static readonly string RootLocale = ""root""; + private static string Rule0(PluralContext context) + { + if ((context.N == 3)) + return ""one""; + + return ""other""; + } + + private static string Rule1(PluralContext context) + { + if ((context.N == 120)) + return ""many""; + + return ""other""; + } + + private static readonly Dictionary Pluralizers = new(StringComparer.OrdinalIgnoreCase) + { + {""root"", new LocalePluralizers(Cardinal: Rule0, Ordinal: Rule1)}, + {""en"", new LocalePluralizers(Cardinal: Rule0, Ordinal: Rule1)}, + {""uk"", new LocalePluralizers(Cardinal: Rule0, Ordinal: null)}, + {""pt-PT"", new LocalePluralizers(Cardinal: null, Ordinal: Rule1)}, + {""pt_PT"", new LocalePluralizers(Cardinal: null, Ordinal: Rule1)}, + }; + + public static partial bool TryGetCardinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer) + { + if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale)) + { + contextPluralizer = null; + return false; + } + contextPluralizer = pluralizersForLocale.Cardinal; + return contextPluralizer != null; + } + + public static partial bool TryGetOrdinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer) + { + if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale)) + { + contextPluralizer = null; + return false; + } + contextPluralizer = pluralizersForLocale.Ordinal; + return contextPluralizer != null; + } + + private record LocalePluralizers(ContextPluralizer? Cardinal, ContextPluralizer? Ordinal); + } +} +".TrimStart(); + + Assert.Equal(expected, actual); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/RuleSourceGeneratorTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/RuleSourceGeneratorTests.cs new file mode 100644 index 0000000..fce52c6 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/RuleSourceGeneratorTests.cs @@ -0,0 +1,335 @@ +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +using System; +using System.Text; + +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.MetadataGenerator; + +public class RuleSourceGeneratorTests +{ + [Fact] + public void CanGenerateEmptyRule() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, Array.Empty())); + + var actual = GenerateText(generator); + var expected = $"return \"other\";{Environment.NewLine}"; + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForFractionNumberEquals() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.V == 0)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForIntegerDigitsEquals() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.IntegerDigits), Relation.Equals, new[] { new NumberOperand(1) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.I == 1)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForModuloEquals() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new ModuloOperand(OperandSymbol.IntegerDigits, 5), Relation.Equals, new[] { new NumberOperand(1) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.I % 5 == 1)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForNumberEquals() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(5) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N == 5)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForNumberNotEquals() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.NotEquals, new[] { new NumberOperand(5) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N != 5)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForNumberRange() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new IRightOperand[] { new RangeOperand(5, 6), new NumberOperand(10) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N >= 5 && context.N <= 6 || context.N == 10)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForNegativeNumberRange() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.NotEquals, new IRightOperand[] { new RangeOperand(5, 6), new NumberOperand(10) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if (((context.N < 5 || context.N > 6) && context.N != 10)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForAndRules() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(4) }), + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N == 4) && (context.V == 0)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForMixedRangeAndNumberRangeRules() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new RangeOperand(4, 5) }), + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N >= 4 && context.N <= 5) && (context.V == 0)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForMultipleOrRules() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(4) }) + }), + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N == 4) || (context.V == 0)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForMixedAndOrRules() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new NumberOperand(4) }), + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.NotEquals, new[] { new NumberOperand(0) }) + }), + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N == 4) && (context.V != 0) || (context.V == 0)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + [Fact] + public void CanGenerateRuleForMixedAndOrRangeRules() + { + var generator = new RuleGenerator(new PluralRule(new[] { "en" }, new[] + { + new Condition("one", string.Empty, new[] + { + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] { new RangeOperand(4, 5) }), + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.NotEquals, new[] { new NumberOperand(0) }) + }), + new OrCondition(new [] + { + new Operation(new VariableOperand(OperandSymbol.VisibleFractionDigitNumber), Relation.Equals, new[] { new NumberOperand(0) }) + }) + }) + })); + + var actual = GenerateText(generator); + var expected = @$" +if ((context.N >= 4 && context.N <= 5) && (context.V != 0) || (context.V == 0)) + return ""one""; + +return ""other""; +".TrimStart(); + Assert.Equal(expected, actual); + } + + private string GenerateText(RuleGenerator generator) + { + var sb = new StringBuilder(); + + generator.WriteTo(sb, 0); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/ObjectPoolTests.cs b/src/Jeffijoe.MessageFormat.Tests/ObjectPoolTests.cs new file mode 100644 index 0000000..c530fd3 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/ObjectPoolTests.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Threading.Tasks; + +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests; + +public class ObjectPoolTests +{ + [Fact] + public void Allocate_WhenPoolEmpty_ReturnsNewObject() + { + var pool = new ObjectPool(() => new StringBuilder()); + var sb = pool.Allocate(); + Assert.NotNull(sb); + } + + [Fact] + public void Free_ThenAllocate_ReturnsSameInstance() + { + var pool = new ObjectPool(() => new StringBuilder()); + var sb = pool.Allocate(); + pool.Free(sb); + var sb2 = pool.Allocate(); + Assert.Same(sb, sb2); + } + + [Fact] + public void Allocate_BeyondPoolSize_CreatesNewObjects() + { + var pool = new ObjectPool(() => new StringBuilder(), size: 2); + var a = pool.Allocate(); + var b = pool.Allocate(); + var c = pool.Allocate(); + Assert.NotSame(a, b); + Assert.NotSame(b, c); + Assert.NotSame(a, c); + } + + [Fact] + public void Free_BeyondPoolSize_DoesNotThrow() + { + var pool = new ObjectPool(() => new StringBuilder(), size: 2); + var a = pool.Allocate(); + var b = pool.Allocate(); + var c = pool.Allocate(); + pool.Free(a); + pool.Free(b); + pool.Free(c); // exceeds pool size, should silently discard + } + + [Fact] + public async Task ConcurrentAllocateAndFree_DoesNotThrow() + { + var pool = new ObjectPool(() => new StringBuilder()); + const int ThreadCount = 8; + const int Iterations = 1000; + + var tasks = new Task[ThreadCount]; + for (var t = 0; t < ThreadCount; t++) + { + tasks[t] = Task.Run(() => + { + for (var i = 0; i < Iterations; i++) + { + var sb = pool.Allocate(); + sb.Append("test"); + var output = sb.ToString(); + // Assert we didn't get a dirty builder with data still left in it. + Assert.Equal("test", output); + sb.Clear(); + pool.Free(sb); + } + }); + } + + await Task.WhenAll(tasks); + } +} diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/FormatterRequestCollectionTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/FormatterRequestCollectionTests.cs index 589391d..1ec119b 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Parsing/FormatterRequestCollectionTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Parsing/FormatterRequestCollectionTests.cs @@ -5,91 +5,88 @@ // Copyright (C) Jeff Hansen 2015. All rights reserved. using System.Linq; -using System.Text; - using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Parsing; using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Parsing +namespace Jeffijoe.MessageFormat.Tests.Parsing; + +/// +/// The formatter request collection tests. +/// +public class FormatterRequestCollectionTests { + #region Public Methods and Operators + /// - /// The formatter request collection tests. + /// The clone. /// - public class FormatterRequestCollectionTests + [Fact] + public void Clone() { - #region Public Methods and Operators + var subject = new FormatterRequestCollection(); + subject.Add( + new FormatterRequest( + new Literal(0, 9, 1, 1, new string('a', 10)), + "test", + "test", + "test")); + subject.Add( + new FormatterRequest( + new Literal(10, 19, 1, 1, new string('a', 10)), + "test", + "test", + "test")); + subject.Add( + new FormatterRequest( + new Literal(20, 29, 1, 1, new string('a', 10)), + "test", + "test", + "test")); - /// - /// The clone. - /// - [Fact] - public void Clone() - { - var subject = new FormatterRequestCollection(); - subject.Add( - new FormatterRequest( - new Literal(0, 9, 1, 1, new StringBuilder(new string('a', 10))), - "test", - "test", - "test")); - subject.Add( - new FormatterRequest( - new Literal(10, 19, 1, 1, new StringBuilder(new string('a', 10))), - "test", - "test", - "test")); - subject.Add( - new FormatterRequest( - new Literal(20, 29, 1, 1, new StringBuilder(new string('a', 10))), - "test", - "test", - "test")); - - var cloned = subject.Clone(); - Assert.Equal(subject.Count, cloned.Count()); - - foreach (var clonedReq in cloned) - { - Assert.DoesNotContain(subject, x => ReferenceEquals(x, clonedReq)); - Assert.DoesNotContain(subject, x => x.SourceLiteral == clonedReq.SourceLiteral); - Assert.Contains(subject, x => x.SourceLiteral.StartIndex == clonedReq.SourceLiteral.StartIndex); - } - } + var cloned = subject.Clone(); + Assert.Equal(subject.Count, cloned.Count()); - /// - /// The shift indices. - /// - [Fact] - public void ShiftIndices() + foreach (var clonedReq in cloned) { - var subject = new FormatterRequestCollection(); - subject.Add( - new FormatterRequest( - new Literal(0, 9, 1, 1, new StringBuilder(new string('a', 10))), - "test", - "test", - "test")); - subject.Add( - new FormatterRequest( - new Literal(10, 19, 1, 1, new StringBuilder(new string('a', 10))), - "test", - "test", - "test")); - subject.Add( - new FormatterRequest( - new Literal(20, 29, 1, 1, new StringBuilder(new string('a', 10))), - "test", - "test", - "test")); - subject.ShiftIndices(1, 4); - Assert.Equal(0, subject[0].SourceLiteral.StartIndex); - Assert.Equal(10, subject[1].SourceLiteral.StartIndex); - - Assert.Equal(14, subject[2].SourceLiteral.StartIndex); + Assert.DoesNotContain(subject, x => ReferenceEquals(x, clonedReq)); + Assert.DoesNotContain(subject, x => x.SourceLiteral == clonedReq.SourceLiteral); + Assert.Contains(subject, x => x.SourceLiteral.StartIndex == clonedReq.SourceLiteral.StartIndex); } + } - #endregion + /// + /// The shift indices. + /// + [Fact] + public void ShiftIndices() + { + var subject = new FormatterRequestCollection(); + subject.Add( + new FormatterRequest( + new Literal(0, 9, 1, 1, new string('a', 10)), + "test", + "test", + "test")); + subject.Add( + new FormatterRequest( + new Literal(10, 19, 1, 1, new string('a', 10)), + "test", + "test", + "test")); + subject.Add( + new FormatterRequest( + new Literal(20, 29, 1, 1, new string('a', 10)), + "test", + "test", + "test")); + subject.ShiftIndices(1, 4); + Assert.Equal(0, subject[0].SourceLiteral.StartIndex); + Assert.Equal(10, subject[1].SourceLiteral.StartIndex); + + Assert.Equal(14, subject[2].SourceLiteral.StartIndex); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralParserTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralParserTests.cs index f80eb02..1b04c6a 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralParserTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralParserTests.cs @@ -11,176 +11,186 @@ using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Parsing +namespace Jeffijoe.MessageFormat.Tests.Parsing; + +/// +/// The literal parser tests. +/// +public class LiteralParserTests { + #region Public Methods and Operators + + /// + /// The parse literals_bracket_mismatch. + /// + /// + /// The source. + /// + /// + /// The expected open brace count. + /// + /// + /// The expected close brace count. + /// + [Theory] + [InlineData("{", 1, 0)] + [InlineData("}", 0, 1)] + [InlineData("A beginning {", 1, 0)] + [InlineData("An ending }", 0, 1)] + [InlineData("One { and multiple }}", 1, 2)] + [InlineData("A few {{{{ and one }", 4, 1)] + [InlineData("A few {{{{ and one '}'}", 4, 1)] + [InlineData("A few '{'{{{{ and one '}'}", 4, 1)] + public void ParseLiterals_bracket_mismatch( + string source, + int expectedOpenBraceCount, + int expectedCloseBraceCount) + { + var sb = new StringBuilder(source); + var subject = new LiteralParser(); + var ex = Assert.Throws(() => subject.ParseLiterals(sb)); + Assert.Equal(expectedOpenBraceCount, ex.OpenBraceCount); + Assert.Equal(expectedCloseBraceCount, ex.CloseBraceCount); + } + /// - /// The literal parser tests. + /// The parse literals_count. /// - public class LiteralParserTests + /// + /// The source. + /// + /// + /// The expected match count. + /// + [Theory] + [InlineData("Hello, {something smells {really} weird.}", 1)] + [InlineData("Hello, {something smells {really} weird.}, {Hi}", 2)] + [InlineData("Hello, {something smells {really} weird.}, '{Hi}'", 1)] + public void ParseLiterals_count(string source, int expectedMatchCount) { - #region Public Methods and Operators - - /// - /// The parse literals_bracket_mismatch. - /// - /// - /// The source. - /// - /// - /// The expected open brace count. - /// - /// - /// The expected close brace count. - /// - [Theory] - [InlineData("{", 1, 0)] - [InlineData("}", 0, 1)] - [InlineData("A beginning {", 1, 0)] - [InlineData("An ending }", 0, 1)] - [InlineData("One { and multiple }}", 1, 2)] - [InlineData("A few {{{{ and one }", 4, 1)] - [InlineData("A few {{{{ and one '}'}", 4, 1)] - [InlineData("A few '{'{{{{ and one '}'}", 4, 1)] - public void ParseLiterals_bracket_mismatch( - string source, - int expectedOpenBraceCount, - int expectedCloseBraceCount) - { - var sb = new StringBuilder(source); - var subject = new LiteralParser(); - var ex = Assert.Throws(() => subject.ParseLiterals(sb)); - Assert.Equal(expectedOpenBraceCount, ex.OpenBraceCount); - Assert.Equal(expectedCloseBraceCount, ex.CloseBraceCount); - } - - /// - /// The parse literals_count. - /// - /// - /// The source. - /// - /// - /// The expected match count. - /// - [Theory] - [InlineData("Hello, {something smells {really} weird.}", 1)] - [InlineData("Hello, {something smells {really} weird.}, {Hi}", 2)] - [InlineData("Hello, {something smells {really} weird.}, '{Hi}'", 1)] - public void ParseLiterals_count(string source, int expectedMatchCount) - { - var sb = new StringBuilder(source); - var subject = new LiteralParser(); - var actual = subject.ParseLiterals(sb); - Assert.Equal(expectedMatchCount, actual.Count()); - } - - /// - /// The parse unclosed_escape_sequence. - /// - /// - /// The source. - /// - /// - /// The expected line number. - /// - /// - /// The expected column number. - /// - [Theory] - [InlineData("'{", 1, 1)] - [InlineData("'}", 1, 1)] - [InlineData("a {b {c} d}, '{open escape sequence}", 1, 14)] - [InlineData(@"Hello, + var sb = new StringBuilder(source); + var subject = new LiteralParser(); + var actual = subject.ParseLiterals(sb); + Assert.Equal(expectedMatchCount, actual.Count()); + } + + /// + /// The parse unclosed_escape_sequence. + /// + /// + /// The source. + /// + /// + /// The expected line number. + /// + /// + /// The expected column number. + /// + [Theory] + [InlineData("'{", 1, 1)] + [InlineData("'}", 1, 1)] + [InlineData("a {b {c} d}, '{open escape sequence}", 1, 14)] + [InlineData(@"Hello, '{World}", 2, 1)] - public void ParseLiterals_unclosed_escape_sequence( - string source, - int expectedLineNumber, - int expectedColumnNumber) - { - var sb = new StringBuilder(source); - var subject = new LiteralParser(); - var ex = Assert.Throws(() => subject.ParseLiterals(sb)); - Assert.Equal(expectedLineNumber, ex.LineNumber); - Assert.Equal(expectedColumnNumber, ex.ColumnNumber); - } - - /// - /// The parse literals_position_and_inner_text. - /// - /// - /// The source. - /// - /// - /// The position. - /// - /// - /// The expected inner text. - /// - [Theory] - [InlineData("Hello, {something smells {really} weird.}", new[] { 7, 40 }, "something smells {really} weird.")] - [InlineData("Pretty {sweet}, right?", new[] { 7, 13 }, "sweet")] - [InlineData(@"{ + public void ParseLiterals_unclosed_escape_sequence( + string source, + int expectedLineNumber, + int expectedColumnNumber) + { + var sb = new StringBuilder(source); + var subject = new LiteralParser(); + var ex = Assert.Throws(() => subject.ParseLiterals(sb)); + Assert.Equal(expectedLineNumber, ex.LineNumber); + Assert.Equal(expectedColumnNumber, ex.ColumnNumber); + } + + /// + /// The parse literals_position_and_inner_text. + /// + /// + /// The source. + /// + /// + /// The position. + /// + /// + /// The expected inner text. + /// + [Theory] + [InlineData("Hello, {something smells {really} weird.}", new[] { 7, 40 }, "something smells {really} weird.")] + [InlineData("Pretty {sweet}, right?", new[] { 7, 13 }, "sweet")] + [InlineData(@"{ +sweet + +}, right?", new[] { 0, 9 }, @" sweet -}, right?", new[] { 0, 9 }, @"sweet")] - [InlineData(@"{ +")] + [InlineData(@"{ '{sweet}' -}, right?", new[] { 0, 13 }, @"'{sweet}'")] - public void ParseLiterals_position_and_inner_text(string source, int[] position, string expectedInnerText) - { - var sb = new StringBuilder(source); - var subject = new LiteralParser(); - var actual = subject.ParseLiterals(sb); - var first = actual.First(); - string innerText = first.InnerText.ToString(); - Assert.Equal(expectedInnerText, innerText); - Assert.Equal(position[0], first.StartIndex); - - // Makes up for line-ending differences due to Git. - var expectedEndIndex = position[1] + source.Count(c => c == '\r'); - var expectedSourceColumnNumber = first.StartIndex + 1; - Assert.Equal(expectedEndIndex, first.EndIndex); - - Assert.Equal(expectedSourceColumnNumber, first.SourceColumnNumber); - } - - /// - /// The parse literals_source_line_and_column_number. - /// - /// - /// The source. - /// - /// - /// The line number. - /// - /// - /// The column number. - /// - [Theory] - [InlineData(@"Hi, this is +}, right?", new[] { 0, 13 }, @" +'{sweet}' + +")] + public void ParseLiterals_position_and_inner_text(string source, int[] position, string expectedInnerText) + { + // It seems that depending on platform this is compiled on, the actual representation of new lines in the + // string literals can differ, which can make this test fail due to differences. + // This will normalize those changes. + expectedInnerText = expectedInnerText.Replace("\r\n", "\n"); + + var sb = new StringBuilder(source); + var subject = new LiteralParser(); + var actual = subject.ParseLiterals(sb); + var first = actual.First(); + string innerText = first.InnerText; + Assert.Equal(expectedInnerText, innerText); + Assert.Equal(position[0], first.StartIndex); + + // Makes up for line-ending differences due to Git. + var expectedEndIndex = position[1] + source.Count(c => c == '\r'); + var expectedSourceColumnNumber = first.StartIndex + 1; + Assert.Equal(expectedEndIndex, first.EndIndex); + + Assert.Equal(expectedSourceColumnNumber, first.SourceColumnNumber); + } + + /// + /// The parse literals_source_line_and_column_number. + /// + /// + /// The source. + /// + /// + /// The line number. + /// + /// + /// The column number. + /// + [Theory] + [InlineData(@"Hi, this is {a tricky one} yeeah! ", 3, 1)] - [InlineData(@"Hi, this is + [InlineData(@"Hi, this is {a tricky one} yeeah! ", 4, 3)] - public void ParseLiterals_source_line_and_column_number(string source, int lineNumber, int columnNumber) - { - var sb = new StringBuilder(source); - var subject = new LiteralParser(); - var actual = subject.ParseLiterals(sb); - var first = actual.First(); - Assert.Equal(lineNumber, first.SourceLineNumber); - Assert.Equal(columnNumber, first.SourceColumnNumber); - } - - #endregion + public void ParseLiterals_source_line_and_column_number(string source, int lineNumber, int columnNumber) + { + var sb = new StringBuilder(source); + var subject = new LiteralParser(); + var actual = subject.ParseLiterals(sb); + var first = actual.First(); + Assert.Equal(lineNumber, first.SourceLineNumber); + Assert.Equal(columnNumber, first.SourceColumnNumber); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralTests.cs index dd55cc7..a6500f0 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Parsing/LiteralTests.cs @@ -4,37 +4,34 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. -using System.Text; - using Jeffijoe.MessageFormat.Parsing; using Xunit; -namespace Jeffijoe.MessageFormat.Tests.Parsing +namespace Jeffijoe.MessageFormat.Tests.Parsing; + +/// +/// The literal tests. +/// +public class LiteralTests { + #region Public Methods and Operators + /// - /// The literal tests. + /// The shift indices. /// - public class LiteralTests + [Fact] + public void ShiftIndices() { - #region Public Methods and Operators + var subject = new Literal(20, 29, 1, 1, new string('a', 10)); + var other = new Literal(5, 10, 1, 1, new string('a', 6)); - /// - /// The shift indices. - /// - [Fact] - public void ShiftIndices() - { - var subject = new Literal(20, 29, 1, 1, new StringBuilder(new string('a', 10))); - var other = new Literal(5, 10, 1, 1, new StringBuilder(new string('a', 6))); + subject.ShiftIndices(2, other); - subject.ShiftIndices(2, other); - - // I honestly have no explanation for this, but it works with the formatter. Magic? - Assert.Equal(18, subject.StartIndex); - Assert.Equal(27, subject.EndIndex); - } - - #endregion + // I honestly have no explanation for this, but it works with the formatter. Magic? + Assert.Equal(18, subject.StartIndex); + Assert.Equal(27, subject.EndIndex); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserGetKeyTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserGetKeyTests.cs deleted file mode 100644 index 132ed3e..0000000 --- a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserGetKeyTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -// MessageFormat for .NET -// - PatternParser_GetKey_Tests.cs -// -// Author: Jeff Hansen -// Copyright (C) Jeff Hansen 2015. All rights reserved. - -using System.Collections.Generic; -using System.Text; - -using Jeffijoe.MessageFormat.Parsing; - -using Xunit; -using Xunit.Abstractions; - -namespace Jeffijoe.MessageFormat.Tests.Parsing -{ - /// - /// The pattern parser_ get key_ tests. - /// - public class PatternParserGetKeyTests - { - #region Fields - - /// - /// The output helper. - /// - private ITestOutputHelper outputHelper; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public PatternParserGetKeyTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } - - #endregion - - #region Public Properties - - /// - /// Gets the get key_throws_with_invalid_characters_ case. - /// - public static IEnumerable GetKey_throws_with_invalid_characters_Case - { - get - { - yield return new object[] { new Literal(3, 10, 1, 3, new StringBuilder("Hellåw,")), 1, 8 }; - yield return new object[] { new Literal(0, 0, 3, 3, new StringBuilder(",")), 3, 4 }; - yield return new object[] { new Literal(0, 0, 3, 3, new StringBuilder(" hello dawg")), 0, 0 }; - yield return new object[] { new Literal(0, 0, 3, 3, new StringBuilder("hello dawg ")), 0, 0 }; - yield return new object[] { new Literal(0, 0, 3, 3, new StringBuilder(" hello dawg")), 0, 0 }; - yield return new object[] { new Literal(0, 0, 3, 3, new StringBuilder(" hello\r\ndawg")), 0, 0 }; - } - } - - #endregion - - #region Public Methods and Operators - - /// - /// The read literal section. - /// - /// - /// The source. - /// - /// - /// The expected. - /// - /// - /// The expected last index. - /// - [Theory] - [InlineData("SupDawg, yeah", "SupDawg", 7)] - [InlineData("hello", "hello", 4)] - [InlineData(" hello ", "hello", 6)] - [InlineData("\r\nhello ", "hello", 7)] - [InlineData("0,", "0", 1)] - [InlineData("0, ", "0", 1)] - [InlineData("0 ,", "0", 2)] - [InlineData("0", "0", 0)] - public void ReadLiteralSection(string source, string expected, int expectedLastIndex) - { - var literal = new Literal(10, 10, 1, 1, new StringBuilder(source)); - int lastIndex; - Assert.Equal(expected, PatternParser.ReadLiteralSection(literal, 0, false, out lastIndex)); - Assert.Equal(expectedLastIndex, lastIndex); - } - - /// - /// The read literal section_throws_with_invalid_characters. - /// - /// - /// The literal. - /// - /// - /// The expected line. - /// - /// - /// The expected column. - /// - [Theory] - [MemberData(nameof(GetKey_throws_with_invalid_characters_Case))] - public void ReadLiteralSection_throws_with_invalid_characters( - Literal literal, - int expectedLine, - int expectedColumn) - { - int lastIndex; - var ex = - Assert.Throws( - () => PatternParser.ReadLiteralSection(literal, 0, false, out lastIndex)); - Assert.Equal(expectedLine, ex.LineNumber); - Assert.Equal(expectedColumn, ex.ColumnNumber); - this.outputHelper.WriteLine(ex.Message); - } - - /// - /// The read literal section_with_offset. - /// - /// - /// The source. - /// - /// - /// The expected. - /// - /// - /// The offset. - /// - [Theory] - [InlineData("SupDawg, yeah", "yeah", 8)] - [InlineData("SupDawg,yeah", "yeah", 8)] - [InlineData("SupDawg,yeah ", "yeah", 8)] - [InlineData("SupDawg, ", null, 8)] - [InlineData("SupDawg,", null, 8)] - public void ReadLiteralSection_with_offset(string source, string expected, int offset) - { - var literal = new Literal(10, 10, 1, 1, new StringBuilder(source)); - int lastIndex; - Assert.Equal(expected, PatternParser.ReadLiteralSection(literal, offset, true, out lastIndex)); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserParseTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserParseTests.cs deleted file mode 100644 index 1b7b12f..0000000 --- a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserParseTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -// MessageFormat for .NET -// - PatternParser_Parse_Tests.cs -// -// Author: Jeff Hansen -// Copyright (C) Jeff Hansen 2015. All rights reserved. - -using System.Linq; -using System.Text; - -using Jeffijoe.MessageFormat.Parsing; -using Jeffijoe.MessageFormat.Tests.TestHelpers; - -using Moq; - -using Xunit; -using Xunit.Abstractions; - -namespace Jeffijoe.MessageFormat.Tests.Parsing -{ - /// - /// The pattern parser_ parse_ tests. - /// - public class PatternParserParseTests - { - #region Fields - - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public PatternParserParseTests(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } - - #endregion - - #region Public Methods and Operators - - /// - /// The parse. - /// - /// - /// The source. - /// - /// - /// The expected key. - /// - /// - /// The expected format. - /// - /// - /// The expected args. - /// - [Theory] - [InlineData("test, select, args", "test", "select", "args")] - [InlineData("test, select, stuff {dawg}", "test", "select", "stuff {dawg}")] - [InlineData("test, select, stuff {dawg's}", "test", "select", "stuff {dawg's}")] - [InlineData("test, select, stuff {dawg''s}", "test", "select", "stuff {dawg''s}")] - [InlineData("test, select, stuff '{{dawg}}'", "test", "select", "stuff '{{dawg}}'")] - [InlineData("test, select, stuff {dawg, select, {name is '{'{name}'}'}}", "test", "select", - "stuff {dawg, select, {name is '{'{name}'}'}}")] - public void Parse(string source, string expectedKey, string expectedFormat, string expectedArgs) - { - var literalParserMock = new Mock(); - var sb = new StringBuilder(source); - literalParserMock.Setup(x => x.ParseLiterals(sb)); - literalParserMock.Setup(x => x.ParseLiterals(sb)) - .Returns(new[] { new Literal(0, source.Length, 1, 1, new StringBuilder(source)) }); - - var subject = new PatternParser(literalParserMock.Object); - - // Warm up (JIT) - Benchmark.Start("Parsing formatter patterns (first time before JIT)", this.outputHelper); - subject.Parse(sb); - Benchmark.End(this.outputHelper); - Benchmark.Start("Parsing formatter patterns (after warm-up)", this.outputHelper); - var actual = subject.Parse(sb); - Benchmark.End(this.outputHelper); - Assert.Single(actual); - var first = actual.First(); - Assert.Equal(expectedKey, first.Variable); - Assert.Equal(expectedFormat, first.FormatterName); - Assert.Equal(expectedArgs, first.FormatterArguments); - } - - /// - /// The parse_exits_early_when_no_literals_have_been_found. - /// - [Fact] - public void Parse_exits_early_when_no_literals_have_been_found() - { - var literalParserMock = new Mock(); - var subject = new PatternParser(literalParserMock.Object); - literalParserMock.Setup(x => x.ParseLiterals(It.IsAny())).Returns(new Literal[0]); - Assert.Empty(subject.Parse(new StringBuilder())); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserWithRealLiteralParser.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserWithRealLiteralParser.cs deleted file mode 100644 index 42df017..0000000 --- a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParser/PatternParserWithRealLiteralParser.cs +++ /dev/null @@ -1,82 +0,0 @@ -// MessageFormat for .NET -// - PatternParser_with_real_LiteralParser.cs -// -// Author: Jeff Hansen -// Copyright (C) Jeff Hansen 2015. All rights reserved. - -using System.Linq; -using System.Text; - -using Jeffijoe.MessageFormat.Parsing; -using Jeffijoe.MessageFormat.Tests.TestHelpers; - -using Xunit; -using Xunit.Abstractions; - -namespace Jeffijoe.MessageFormat.Tests.Parsing -{ - /// - /// The pattern parser_with_real_ literal parser. - /// - public class PatternParserWithRealLiteralParser - { - #region Fields - - /// - /// The output helper. - /// - private readonly ITestOutputHelper outputHelper; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The output helper. - /// - public PatternParserWithRealLiteralParser(ITestOutputHelper outputHelper) - { - this.outputHelper = outputHelper; - } - - #endregion - - #region Public Methods and Operators - - /// - /// The parse. - /// - [Fact] - public void Parse() - { - var subject = new PatternParser(new LiteralParser()); - - const string Source = @"Hi, {Name, select, - male={guy} female={gal}}, you have {count, plural, - zero {no friends}, other {# friends} - }"; - Benchmark.Start("First run (warm-up)", this.outputHelper); - subject.Parse(new StringBuilder(Source)); - Benchmark.End(this.outputHelper); - - Benchmark.Start("Next one (warmed up)", this.outputHelper); - var actual = subject.Parse(new StringBuilder(Source)); - Benchmark.End(this.outputHelper); - Assert.Equal(2, actual.Count()); - var formatterParam = actual.First(); - Assert.Equal("Name", formatterParam.Variable); - Assert.Equal("select", formatterParam.FormatterName); - Assert.Equal("male={guy} female={gal}", formatterParam.FormatterArguments); - - formatterParam = actual.ElementAt(1); - Assert.Equal("count", formatterParam.Variable); - Assert.Equal("plural", formatterParam.FormatterName); - Assert.Equal("zero {no friends}, other {# friends}", formatterParam.FormatterArguments); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserGetKeyTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserGetKeyTests.cs new file mode 100644 index 0000000..86a49ad --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserGetKeyTests.cs @@ -0,0 +1,146 @@ +// MessageFormat for .NET +// - PatternParser_GetKey_Tests.cs +// +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +using System.Collections.Generic; +using Jeffijoe.MessageFormat.Parsing; +using Xunit; +using Xunit.Abstractions; + +namespace Jeffijoe.MessageFormat.Tests.Parsing; + +/// +/// The pattern parser_ get key_ tests. +/// +public class PatternParserGetKeyTests +{ + #region Fields + + /// + /// The output helper. + /// + private ITestOutputHelper outputHelper; + + #endregion + + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public PatternParserGetKeyTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } + + #endregion + + #region Public Properties + + /// + /// Gets the get key_throws_with_invalid_characters_ case. + /// + public static IEnumerable GetKeyThrowsWithInvalidCharactersCase + { + get + { + yield return new object[] { new Literal(3, 10, 1, 3, "Hellåw,"), 1, 8 }; + yield return new object[] { new Literal(0, 0, 3, 3, ","), 3, 4 }; + yield return new object[] { new Literal(0, 0, 3, 3, " hello dawg"), 0, 0 }; + yield return new object[] { new Literal(0, 0, 3, 3, "hello dawg "), 0, 0 }; + yield return new object[] { new Literal(0, 0, 3, 3, " hello dawg"), 0, 0 }; + yield return new object[] { new Literal(0, 0, 3, 3, " hello\r\ndawg"), 0, 0 }; + } + } + + #endregion + + #region Public Methods and Operators + + /// + /// The read literal section. + /// + /// + /// The source. + /// + /// + /// The expected. + /// + /// + /// The expected last index. + /// + [Theory] + [InlineData("SupDawg, yeah", "SupDawg", 7)] + [InlineData("hello", "hello", 4)] + [InlineData(" hello ", "hello", 6)] + [InlineData("\r\nhello ", "hello", 7)] + [InlineData("0,", "0", 1)] + [InlineData("0, ", "0", 1)] + [InlineData("0 ,", "0", 2)] + [InlineData("0", "0", 0)] + public void ReadLiteralSection(string source, string expected, int expectedLastIndex) + { + var literal = new Literal(10, 10, 1, 1, source); + int lastIndex; + Assert.Equal(expected, PatternParser.ReadLiteralSection(literal, 0, false, out lastIndex)); + Assert.Equal(expectedLastIndex, lastIndex); + } + + /// + /// The read literal section_throws_with_invalid_characters. + /// + /// + /// The literal. + /// + /// + /// The expected line. + /// + /// + /// The expected column. + /// + [Theory] + [MemberData(nameof(GetKeyThrowsWithInvalidCharactersCase))] + public void ReadLiteralSection_throws_with_invalid_characters( + Literal literal, + int expectedLine, + int expectedColumn) + { + var ex = + Assert.Throws( + () => PatternParser.ReadLiteralSection(literal, 0, false, out _)); + Assert.Equal(expectedLine, ex.LineNumber); + Assert.Equal(expectedColumn, ex.ColumnNumber); + this.outputHelper.WriteLine(ex.Message); + } + + /// + /// The read literal section_with_offset. + /// + /// + /// The source. + /// + /// + /// The expected. + /// + /// + /// The offset. + /// + [Theory] + [InlineData("SupDawg, yeah", "yeah", 8)] + [InlineData("SupDawg,yeah", "yeah", 8)] + [InlineData("SupDawg,yeah ", "yeah", 8)] + [InlineData("SupDawg, ", null, 8)] + [InlineData("SupDawg,", null, 8)] + public void ReadLiteralSection_with_offset(string source, string? expected, int offset) + { + var literal = new Literal(10, 10, 1, 1, source); + Assert.Equal(expected, PatternParser.ReadLiteralSection(literal, offset, true, out _)); + } + + #endregion +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserParseTests.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserParseTests.cs new file mode 100644 index 0000000..5c57718 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserParseTests.cs @@ -0,0 +1,112 @@ +// MessageFormat for .NET +// - PatternParser_Parse_Tests.cs +// +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +using System.Text; + +using Jeffijoe.MessageFormat.Parsing; +using Jeffijoe.MessageFormat.Tests.TestHelpers; + +using Xunit; +using Xunit.Abstractions; + +namespace Jeffijoe.MessageFormat.Tests.Parsing; + +/// +/// The pattern parser_ parse_ tests. +/// +public class PatternParserParseTests +{ + #region Fields + + /// + /// The output helper. + /// + private readonly ITestOutputHelper outputHelper; + + #endregion + + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public PatternParserParseTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } + + #endregion + + #region Public Methods and Operators + + /// + /// The parse. + /// + /// + /// The source. + /// + /// + /// The expected key. + /// + /// + /// The expected format. + /// + /// + /// The expected args. + /// + [Theory] + [InlineData("test, select, args", "test", "select", "args")] + [InlineData("test, select, stuff {dawg}", "test", "select", "stuff {dawg}")] + [InlineData("test, select, stuff {dawg's}", "test", "select", "stuff {dawg's}")] + [InlineData("test, select, stuff {dawg''s}", "test", "select", "stuff {dawg''s}")] + [InlineData("test, select, stuff '{{dawg}}'", "test", "select", "stuff '{{dawg}}'")] + [InlineData("test, select, stuff {dawg, select, {name is '{'{name}'}'}}", "test", "select", + "stuff {dawg, select, {name is '{'{name}'}'}}")] + public void Parse(string source, string expectedKey, string expectedFormat, string expectedArgs) + { + var literalParser = FakeLiteralParser.Of(source); + var sb = new StringBuilder(source); + var subject = new PatternParser(literalParser); + + // Warm up (JIT) + Benchmark.Start("Parsing formatter patterns (first time before JIT)", this.outputHelper); + subject.Parse(sb); + Benchmark.End(this.outputHelper); + Benchmark.Start("Parsing formatter patterns (after warm-up)", this.outputHelper); + var actual = subject.Parse(sb); + Benchmark.End(this.outputHelper); + Assert.Single(actual); + var first = actual[0]; + Assert.Equal(expectedKey, first.Variable); + Assert.Equal(expectedFormat, first.FormatterName); + Assert.Equal(expectedArgs, first.FormatterArguments); + } + + /// + /// The parse_exits_early_when_no_literals_have_been_found. + /// + [Fact] + public void Parse_exits_early_when_no_literals_have_been_found() + { + var subject = new PatternParser(); + Assert.Empty(subject.Parse(new StringBuilder())); + } + + /// + /// The parse_throws_when_only_whitespace_is_present_in_section + /// + [Fact] + public void Parse_throws_when_only_whitespace_is_present_in_section() + { + var subject = new PatternParser(); + Assert.Throws(() => subject.Parse(new StringBuilder("{ }"))); + } + + #endregion +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserWithRealLiteralParser.cs b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserWithRealLiteralParser.cs new file mode 100644 index 0000000..d0b4ec2 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Parsing/PatternParserWithRealLiteralParser.cs @@ -0,0 +1,81 @@ +// MessageFormat for .NET +// - PatternParser_with_real_LiteralParser.cs +// +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +using System.Linq; +using System.Text; + +using Jeffijoe.MessageFormat.Parsing; +using Jeffijoe.MessageFormat.Tests.TestHelpers; + +using Xunit; +using Xunit.Abstractions; + +namespace Jeffijoe.MessageFormat.Tests.Parsing; + +/// +/// The pattern parser_with_real_ literal parser. +/// +public class PatternParserWithRealLiteralParser +{ + #region Fields + + /// + /// The output helper. + /// + private readonly ITestOutputHelper outputHelper; + + #endregion + + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + /// + /// The output helper. + /// + public PatternParserWithRealLiteralParser(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper; + } + + #endregion + + #region Public Methods and Operators + + /// + /// The parse. + /// + [Fact] + public void Parse() + { + var subject = new PatternParser(new LiteralParser()); + + const string Source = @"Hi, {Name, select, + male={guy} female={gal}}, you have {count, plural, + zero {no friends}, other {# friends} + }"; + Benchmark.Start("First run (warm-up)", this.outputHelper); + subject.Parse(new StringBuilder(Source)); + Benchmark.End(this.outputHelper); + + Benchmark.Start("Next one (warmed up)", this.outputHelper); + var actual = subject.Parse(new StringBuilder(Source)); + Benchmark.End(this.outputHelper); + Assert.Equal(2, actual.Count()); + var formatterParam = actual.First(); + Assert.Equal("Name", formatterParam.Variable); + Assert.Equal("select", formatterParam.FormatterName); + Assert.Equal("male={guy} female={gal}", formatterParam.FormatterArguments); + + formatterParam = actual.ElementAt(1); + Assert.Equal("count", formatterParam.Variable); + Assert.Equal("plural", formatterParam.FormatterName); + Assert.Equal("zero {no friends}, other {# friends}", formatterParam.FormatterArguments); + } + + #endregion +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/Properties/AssemblyInfo.cs b/src/Jeffijoe.MessageFormat.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index cec1201..0000000 --- a/src/Jeffijoe.MessageFormat.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,39 +0,0 @@ -// MessageFormat for .NET -// - AssemblyInfo.cs -// -// Author: Jeff Hansen -// Copyright (C) Jeff Hansen 2015. All rights reserved. - -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Jeffijoe.MessageFormat.Tests")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Jeffijoe.MessageFormat.Tests")] -[assembly: AssemblyCopyright("Copyright © 2014")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("1f34c9fb-42dc-432f-8719-689679fce52b")] - -// Version information for an assembly consists of the following four values: -// Major Version -// Minor Version -// Build Number -// Revision -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/Benchmark.cs b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/Benchmark.cs index 416a588..011717c 100644 --- a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/Benchmark.cs +++ b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/Benchmark.cs @@ -8,51 +8,50 @@ using Xunit.Abstractions; -namespace Jeffijoe.MessageFormat.Tests.TestHelpers +namespace Jeffijoe.MessageFormat.Tests.TestHelpers; + +/// +/// Benchmark helper. +/// +public static class Benchmark { + #region Static Fields + + /// + /// The stopwatch. + /// + private static readonly Stopwatch Sw = new Stopwatch(); + + #endregion + + #region Public Methods and Operators + /// - /// Benchmark helper. + /// Ends the benchmark and prints the elapsed time to the console. /// - public static class Benchmark + /// + /// The output helper. + /// + public static void End(ITestOutputHelper outputHelper) { - #region Static Fields - - /// - /// The stopwatch. - /// - private static readonly Stopwatch Sw = new Stopwatch(); - - #endregion - - #region Public Methods and Operators - - /// - /// Ends the benchmark and prints the elapsed time to the console. - /// - /// - /// The output helper. - /// - public static void End(ITestOutputHelper outputHelper) - { - Sw.Stop(); - outputHelper.WriteLine("Result: {0}ms ({1} ticks)", Sw.ElapsedMilliseconds, Sw.ElapsedTicks); - } - - /// - /// Starts the benchmark, and writes the passed message to the output helper. - /// - /// - /// The message for console. - /// - /// - /// The output helper. - /// - public static void Start(string messageForConsole, ITestOutputHelper outputHelper) - { - outputHelper.WriteLine(messageForConsole); - Sw.Restart(); - } - - #endregion + Sw.Stop(); + outputHelper.WriteLine("Result: {0}ms ({1} ticks)", Sw.ElapsedMilliseconds, Sw.ElapsedTicks); } + + /// + /// Starts the benchmark, and writes the passed message to the output helper. + /// + /// + /// The message for console. + /// + /// + /// The output helper. + /// + public static void Start(string messageForConsole, ITestOutputHelper outputHelper) + { + outputHelper.WriteLine(messageForConsole); + Sw.Restart(); + } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeFormatter.cs b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeFormatter.cs new file mode 100644 index 0000000..ef6fae7 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeFormatter.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Globalization; +using Jeffijoe.MessageFormat.Formatting; + +namespace Jeffijoe.MessageFormat.Tests.TestHelpers; + +/// +/// Fake formatter used for testing. +/// +internal class FakeFormatter : IFormatter +{ + /// + /// What to return when is called. + /// + private readonly string formatResult; + + /// + /// Whether we should announce that we can format the input. + /// + private bool canFormat; + + /// + /// Initializes a new instance of the class. + /// + /// Whether to return true for . + /// The result to return. + public FakeFormatter(bool canFormat = false, string formatResult = "formatted") + { + this.canFormat = canFormat; + this.formatResult = formatResult; + } + + /// + public bool VariableMustExist => false; + + /// + public bool CanFormat(FormatterRequest request) => this.canFormat; + + /// + /// Sets the value of what returns. + /// + /// + public void SetCanFormat(bool value) => this.canFormat = value; + + /// + public string Format( + CultureInfo culture, + FormatterRequest request, + IReadOnlyDictionary args, + object? value, + IMessageFormatter messageFormatter) => + formatResult; +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeLiteralParser.cs b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeLiteralParser.cs new file mode 100644 index 0000000..841099b --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeLiteralParser.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Text; +using Jeffijoe.MessageFormat.Parsing; + +namespace Jeffijoe.MessageFormat.Tests.TestHelpers; + +/// +/// Fake literal parser. +/// +internal class FakeLiteralParser : ILiteralParser +{ + /// + /// The literal to return. + /// + private readonly Literal literal; + + /// + /// Initializes a new instance of the class. + /// + /// + public FakeLiteralParser(Literal literal) + { + this.literal = literal; + } + + /// + public IEnumerable ParseLiterals(StringBuilder sb) + { + yield return literal; + } + + /// + /// Creates a fake literal parser that returns a single literal with + /// the specified inner text. + /// + /// + /// + public static ILiteralParser Of(string innerText) => + new FakeLiteralParser(new Literal(0, innerText.Length, 1, 1, innerText)); +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeMessageFormatter.cs b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeMessageFormatter.cs new file mode 100644 index 0000000..bc3819d --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/FakeMessageFormatter.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Jeffijoe.MessageFormat.Tests.TestHelpers; + +/// +/// Used for testing. Will just pass through the input pattern. +/// +internal class FakeMessageFormatter : IMessageFormatter +{ + public CustomValueFormatter? CustomValueFormatter { get; set; } + + public string FormatMessage(string pattern, IReadOnlyDictionary argsMap, CultureInfo? culture = null) => pattern; + + public string FormatMessage(string pattern, object args) => pattern; +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/TrackingPatternParser.cs b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/TrackingPatternParser.cs new file mode 100644 index 0000000..e1726d0 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/TrackingPatternParser.cs @@ -0,0 +1,35 @@ +using System.Text; +using Jeffijoe.MessageFormat.Parsing; + +namespace Jeffijoe.MessageFormat.Tests.TestHelpers; + +/// +/// Tracks the amount of times Parse is called. +/// +internal class TrackingPatternParser : IPatternParser +{ + /// + /// The real parser. + /// + private readonly PatternParser parser; + + /// + /// Initializes a new instance of the class. + /// + public TrackingPatternParser() + { + parser = new PatternParser(); + } + + /// + /// The amount of times Parse was called. + /// + public int ParseCount { get; private set; } + + /// + public IFormatterRequestCollection Parse(StringBuilder source) + { + ParseCount++; + return parser.Parse(source); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/TestHelpers/UseCultureAttribute.cs b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/UseCultureAttribute.cs new file mode 100644 index 0000000..f3bdf33 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/TestHelpers/UseCultureAttribute.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; +using System.Reflection; +using System.Threading; +using Xunit.Sdk; + +namespace Jeffijoe.MessageFormat.Tests.TestHelpers; + +/// +/// Apply this attribute to your test method to replace the +/// and +/// with another culture. +/// +/// +/// Replaces the culture and UI culture of the current thread with +/// and +/// +/// The name of the culture. +/// The name of the UI culture. +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public class UseCultureAttribute(string culture, string uiCulture) : BeforeAfterTestAttribute +{ + private readonly Lazy culture = new(() => new CultureInfo(culture, false)); + private readonly Lazy uiCulture = new(() => new CultureInfo(uiCulture, false)); + + private CultureInfo? originalCulture; + private CultureInfo? originalUiCulture; + + /// + /// Replaces the culture and UI culture of the current thread with + /// + /// + /// The name of the culture. + /// + /// This constructor overload uses for both Culture and UICulture. + /// + public UseCultureAttribute(string culture) + : this(culture, culture) { } + + /// + /// Stores the current + /// and + /// and replaces them with the new cultures defined in the constructor. + /// + /// The method under test + public override void Before(MethodInfo methodUnderTest) + { + originalCulture = Thread.CurrentThread.CurrentCulture; + originalUiCulture = Thread.CurrentThread.CurrentUICulture; + + Thread.CurrentThread.CurrentCulture = culture.Value; + Thread.CurrentThread.CurrentUICulture = uiCulture.Value; + + CultureInfo.CurrentCulture.ClearCachedData(); + CultureInfo.CurrentUICulture.ClearCachedData(); + } + + /// + /// Restores the original and + /// to + /// + /// The method under test + public override void After(MethodInfo methodUnderTest) + { + if (originalCulture is not null) + Thread.CurrentThread.CurrentCulture = originalCulture; + if (originalUiCulture is not null) + Thread.CurrentThread.CurrentUICulture = originalUiCulture; + + CultureInfo.CurrentCulture.ClearCachedData(); + CultureInfo.CurrentUICulture.ClearCachedData(); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/CustomValueFormatters.cs b/src/Jeffijoe.MessageFormat/CustomValueFormatters.cs new file mode 100644 index 0000000..a2bd68f --- /dev/null +++ b/src/Jeffijoe.MessageFormat/CustomValueFormatters.cs @@ -0,0 +1,209 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Jeffijoe.MessageFormat; + +/// +/// Attempts to format a date. +/// +/// +/// The culture. +/// +/// +/// The value to format. +/// +/// +/// The requested style, if any. +/// +/// +/// Output for setting the formatted result. +/// +/// +/// true if able to format the value; false otherwise. +/// +public delegate bool TryFormatDate( + CultureInfo culture, + object? value, + string? style, + out string? formatted); + +/// +/// Attempts to format a time. +/// +/// +/// The culture. +/// +/// +/// The value to format. +/// +/// +/// The requested style, if any. +/// +/// +/// Output for setting the formatted result. +/// +/// +/// true if able to format the value; false otherwise. +/// +public delegate bool TryFormatTime( + CultureInfo culture, + object? value, + string? style, + out string? formatted); + +/// +/// Attempts to format a number. +/// +/// +/// The culture. +/// +/// +/// The value to format. +/// +/// +/// The requested style, if any. +/// +/// +/// Output for setting the formatted result. +/// +/// +/// true if able to format the value; false otherwise. +/// +public delegate bool TryFormatNumber( + CultureInfo culture, + object? value, + string? style, + out string? formatted); + +/// +/// Base class that can be extended to provide custom formatting +/// for values. +/// +public abstract class CustomValueFormatter +{ + /// + /// Attempts to format a date. + /// + /// + /// The culture. + /// + /// + /// The value to format. + /// + /// + /// The requested style, if any. + /// + /// + /// Output for setting the formatted result. + /// + /// + /// true if able to format the value; false otherwise. + /// + [ExcludeFromCodeCoverage] + public virtual bool TryFormatDate( + CultureInfo culture, + object? value, + string? style, + out string? formatted) + { + formatted = null; + return false; + } + + /// + /// Attempts to format a time. + /// + /// + /// The culture. + /// + /// + /// The value to format. + /// + /// + /// The requested style, if any. + /// + /// + /// Output for setting the formatted result. + /// + /// + /// true if able to format the value; false otherwise. + /// + [ExcludeFromCodeCoverage] + public virtual bool TryFormatTime( + CultureInfo culture, + object? value, + string? style, + out string? formatted) + { + formatted = null; + return false; + } + + /// + /// Attempts to format a number. + /// + /// + /// The culture. + /// + /// + /// The value to format. + /// + /// + /// The requested style, if any. + /// + /// + /// Output for setting the formatted result. + /// + /// + /// true if able to format the value; false otherwise. + /// + [ExcludeFromCodeCoverage] + public virtual bool TryFormatNumber( + CultureInfo culture, + object? value, + string? style, + out string? formatted) + { + formatted = null; + return false; + } +} + +/// +/// Delegates the formatting calls to the configured function properties. +/// +public sealed class CustomValueFormatters : CustomValueFormatter +{ + /// + /// Formatter for dates. + /// + public TryFormatDate? Date { get; set; } + + /// + /// Formatter for times. + /// + public TryFormatDate? Time { get; set; } + + /// + /// Formatter for numbers. + /// + public TryFormatNumber? Number { get; set; } + + /// + public override bool TryFormatDate(CultureInfo culture, object? value, string? style, + out string? formatted) => + this.Date?.Invoke(culture, value, style, out formatted) ?? + base.TryFormatDate(culture, value, style, out formatted); + + /// + public override bool TryFormatTime(CultureInfo culture, object? value, string? style, + out string? formatted) => + this.Time?.Invoke(culture, value, style, out formatted) ?? + base.TryFormatTime(culture, value, style, out formatted); + + /// + public override bool TryFormatNumber(CultureInfo culture, object? value, string? style, + out string? formatted) => + this.Number?.Invoke(culture, value, style, out formatted) ?? + base.TryFormatNumber(culture, value, style, out formatted); +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/FormatterNotFoundException.cs b/src/Jeffijoe.MessageFormat/FormatterNotFoundException.cs index 6ee9904..b111776 100644 --- a/src/Jeffijoe.MessageFormat/FormatterNotFoundException.cs +++ b/src/Jeffijoe.MessageFormat/FormatterNotFoundException.cs @@ -7,50 +7,49 @@ using Jeffijoe.MessageFormat.Formatting; -namespace Jeffijoe.MessageFormat +namespace Jeffijoe.MessageFormat; + +/// +/// Thrown when a formatter could not be found for a specific request. +/// +public class FormatterNotFoundException : Exception { + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + /// + /// The request. + /// + public FormatterNotFoundException(FormatterRequest request) + : base(BuildMessage(request)) + { + } + + #endregion + + #region Methods + /// - /// Thrown when a formatter could not be found for a specific request. + /// Builds the message. /// - public class FormatterNotFoundException : Exception + /// + /// The request. + /// + /// + /// The . + /// + private static string BuildMessage(FormatterRequest request) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The request. - /// - public FormatterNotFoundException(FormatterRequest request) - : base(BuildMessage(request)) - { - } - - #endregion - - #region Methods - - /// - /// Builds the message. - /// - /// - /// The request. - /// - /// - /// The . - /// - private static string BuildMessage(FormatterRequest request) - { - return - string.Format( - "Format '{0}' could not be resolved.\r\n" + "Line {1}, position {2}\r\n" + "Source literal: '{3}'", - request.FormatterName, - request.SourceLiteral.SourceLineNumber, - request.SourceLiteral.SourceColumnNumber, - request.SourceLiteral.InnerText); - } - - #endregion + return + string.Format( + "Format '{0}' could not be resolved.\r\n" + "Line {1}, position {2}\r\n" + "Source literal: '{3}'", + request.FormatterName, + request.SourceLiteral.SourceLineNumber, + request.SourceLiteral.SourceColumnNumber, + request.SourceLiteral.InnerText); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/BaseFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/BaseFormatter.cs index 697c43f..1946de1 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/BaseFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/BaseFormatter.cs @@ -4,68 +4,75 @@ // Copyright (C) Jeff Hansen 2014. All rights reserved. using System.Collections.Generic; -using System.Text; - +using System.Linq; using Jeffijoe.MessageFormat.Parsing; -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Base formatter with helpers for extracting data from the formatter request. +/// +public abstract class BaseFormatter { + #region Constants + /// - /// Base formatter with helpers for extracting data from the formatter request. + /// The other. /// - public abstract class BaseFormatter + protected const string OtherKey = "other"; + + #endregion + + #region Methods + + /// + /// Parses the arguments. + /// + /// + /// The request. + /// + /// + /// The . + /// + protected internal ParsedArguments ParseArguments(FormatterRequest request) { - #region Constants - - /// - /// The other. - /// - protected const string OtherKey = "other"; - - #endregion - - #region Methods - - /// - /// Parses the arguments. - /// - /// - /// The request. - /// - /// - /// The . - /// - protected internal ParsedArguments ParseArguments(FormatterRequest request) + int index; + var extensions = this.ParseExtensions(request, out index); + var keyedBlocks = this.ParseKeyedBlocks(request, index); + return new ParsedArguments(keyedBlocks, extensions); + } + + /// + /// Parses the extensions. + /// + /// The request. + /// The index. + /// The formatter extensions. + protected internal IEnumerable ParseExtensions(FormatterRequest request, out int index) + { + var result = new List(); + if (request.FormatterArguments == null) { - int index; - var extensions = this.ParseExtensions(request, out index); - var keyedBlocks = this.ParseKeyedBlocks(request, index); - return new ParsedArguments(keyedBlocks, extensions); + index = -1; + return Enumerable.Empty(); } - - /// - /// Parses the extensions. - /// - /// The request. - /// The index. - /// The formatter extensions. - protected internal IEnumerable ParseExtensions(FormatterRequest request, out int index) + + var length = request.FormatterArguments.Length; + index = 0; + const char Colon = ':'; + const char OpenBrace = '{'; + var foundExtension = false; + + var extension = StringBuilderPool.Get(); + var value = StringBuilderPool.Get(); + try { - var result = new List(); - int length = request.FormatterArguments.Length; - index = 0; - - var extension = new StringBuilder(); - var value = new StringBuilder(); - - const char Colon = ':'; - bool foundExtension = false; - for (int i = 0; i < length; i++) + for (var i = 0; i < length; i++) { var c = request.FormatterArguments[i]; // Whitespace is tolerated at the beginning. - bool isWhiteSpace = char.IsWhiteSpace(c); + var isWhiteSpace = char.IsWhiteSpace(c); if (isWhiteSpace) { // We've reached the end @@ -100,36 +107,56 @@ protected internal IEnumerable ParseExtensions(FormatterRequ continue; } + if (c == OpenBrace) + { + // It's not an extension. + break; + } + extension.Append(c); } return result; } + finally + { + StringBuilderPool.Return(extension); + StringBuilderPool.Return(value); + } + } - /// - /// Parses the keyed blocks. - /// - /// - /// The request. - /// - /// - /// The start index. - /// - /// - /// The keyed blocks. - /// - protected internal IEnumerable ParseKeyedBlocks(FormatterRequest request, int startIndex) + /// + /// Parses the keyed blocks. + /// + /// + /// The request. + /// + /// + /// The start index. + /// + /// + /// The keyed blocks. + /// + protected internal IEnumerable ParseKeyedBlocks(FormatterRequest request, int startIndex) + { + const char OpenBrace = '{'; + const char CloseBrace = '}'; + const char EscapingChar = '\''; + + var result = new List(); + var braceBalance = 0; + var foundWhitespaceAfterKey = false; + var insideEscapeSequence = false; + if (request.FormatterArguments == null) + { + return Enumerable.Empty(); + } + + var key = StringBuilderPool.Get(); + var block = StringBuilderPool.Get(); + + try { - const char OpenBrace = '{'; - const char CloseBrace = '}'; - const char EscapingChar = '\''; - - var result = new List(); - var key = new StringBuilder(); - var block = new StringBuilder(); - var braceBalance = 0; - var foundWhitespaceAfterKey = false; - var insideEscapeSequence = false; for (int i = startIndex; i < request.FormatterArguments.Length; i++) { var c = request.FormatterArguments[i]; @@ -156,6 +183,7 @@ protected internal IEnumerable ParseKeyedBlocks(FormatterRequest req { insideEscapeSequence = false; } + continue; } @@ -229,7 +257,8 @@ protected internal IEnumerable ParseKeyedBlocks(FormatterRequest req { throw new MalformedLiteralException( "Found end of a block, but no block has been started, or the" - + " block has already been closed. " + "This could indicate an unescaped brace somewhere.", + + " block has already been closed. " + + "This could indicate an unescaped brace somewhere.", 0, 0, request.FormatterArguments); @@ -244,15 +273,6 @@ protected internal IEnumerable ParseKeyedBlocks(FormatterRequest req foundWhitespaceAfterKey = false; continue; } - - if (braceBalance < 0) - { - throw new MalformedLiteralException( - "Expected '{', but found '}' - essentially this means there are more close braces than there are open braces.", - 0, - 0, - request.FormatterArguments); - } } // If we are inside a block, append to the block buffer @@ -302,7 +322,12 @@ protected internal IEnumerable ParseKeyedBlocks(FormatterRequest req return result; } - - #endregion + finally + { + StringBuilderPool.Return(key); + StringBuilderPool.Return(block); + } } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/FormatterExtension.cs b/src/Jeffijoe.MessageFormat/Formatting/FormatterExtension.cs index fdc2d4e..abc3a07 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/FormatterExtension.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/FormatterExtension.cs @@ -2,51 +2,50 @@ // - FormatterExtension.cs // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Contains extensions to be used by formatters. +/// Example, the offset extension for the Plural Format. +/// +public class FormatterExtension { + #region Constructors and Destructors + /// - /// Contains extensions to be used by formatters. - /// Example, the offset extension for the Plural Format. + /// Initializes a new instance of the class. /// - public class FormatterExtension + /// + /// The extension. + /// + /// + /// The value. + /// + public FormatterExtension(string extension, string value) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The extension. - /// - /// - /// The value. - /// - public FormatterExtension(string extension, string value) - { - this.Extension = extension; - this.Value = value; - } + this.Extension = extension; + this.Value = value; + } - #endregion + #endregion - #region Public Properties + #region Public Properties - /// - /// Gets the extension. - /// - /// - /// The extension. - /// - public string Extension { get; private set; } + /// + /// Gets the extension. + /// + /// + /// The extension. + /// + public string Extension { get; private set; } - /// - /// Gets the value. - /// - /// - /// The value. - /// - public string Value { get; private set; } + /// + /// Gets the value. + /// + /// + /// The value. + /// + public string Value { get; private set; } - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/FormatterLibrary.cs b/src/Jeffijoe.MessageFormat/Formatting/FormatterLibrary.cs index d887535..b8543ba 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/FormatterLibrary.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/FormatterLibrary.cs @@ -4,56 +4,56 @@ // Copyright (C) Jeff Hansen 2014. All rights reserved. using System.Collections.Generic; -using System.Linq; - using Jeffijoe.MessageFormat.Formatting.Formatters; -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Manages formatters to use. +/// +public class FormatterLibrary : List, IFormatterLibrary { + #region Constructors and Destructors + /// - /// Manages formatters to use. + /// Initializes a new instance of the class, and adds the default formatters. /// - public class FormatterLibrary : List, IFormatterLibrary + public FormatterLibrary() { - #region Constructors and Destructors + this.Add(new VariableFormatter()); + this.Add(new SelectFormatter()); + this.Add(new PluralFormatter()); + this.Add(new NumberFormatter()); + this.Add(new DateFormatter()); + this.Add(new TimeFormatter()); + } - /// - /// Initializes a new instance of the class, and adds the default formatters. - /// - public FormatterLibrary() - { - this.Add(new VariableFormatter()); - this.Add(new SelectFormatter()); - this.Add(new PluralFormatter()); - } + #endregion - #endregion - - #region Public Methods and Operators - - /// - /// Gets the formatter to use. If none was found, throws an exception. - /// - /// - /// The request. - /// - /// - /// The . - /// - /// - /// Thrown when the formatter was not found. - /// - public IFormatter GetFormatter(FormatterRequest request) - { - var formatter = this.FirstOrDefault(x => x.CanFormat(request)); - if (formatter == null) - { - throw new FormatterNotFoundException(request); - } + #region Public Methods and Operators - return formatter; + /// + /// Gets the formatter to use. If none was found, throws an exception. + /// + /// + /// The request. + /// + /// + /// The . + /// + /// + /// Thrown when the formatter was not found. + /// + public IFormatter GetFormatter(FormatterRequest request) + { + foreach (var formatter in this) + { + if (formatter.CanFormat(request)) + return formatter; } - #endregion + throw new FormatterNotFoundException(request); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/FormatterRequest.cs b/src/Jeffijoe.MessageFormat/Formatting/FormatterRequest.cs index 38c947d..001e3b1 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/FormatterRequest.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/FormatterRequest.cs @@ -5,93 +5,92 @@ using Jeffijoe.MessageFormat.Parsing; -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Formatter request. +/// +public class FormatterRequest { + #region Constructors and Destructors + /// - /// Formatter request. + /// Initializes a new instance of the class. /// - public class FormatterRequest + /// + /// The source literal. + /// + /// + /// The variable. + /// + /// + /// Name of the formatter. + /// + /// + /// The formatter arguments. + /// + public FormatterRequest(Literal sourceLiteral, string variable, string? formatterName, string? formatterArguments) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The source literal. - /// - /// - /// The variable. - /// - /// - /// Name of the formatter. - /// - /// - /// The formatter arguments. - /// - public FormatterRequest(Literal sourceLiteral, string variable, string formatterName, string formatterArguments) - { - this.SourceLiteral = sourceLiteral; - this.Variable = variable; - this.FormatterName = formatterName; - this.FormatterArguments = formatterArguments; - } - - #endregion + this.SourceLiteral = sourceLiteral; + this.Variable = variable; + this.FormatterName = formatterName; + this.FormatterArguments = formatterArguments; + } - #region Public Properties + #endregion - /// - /// Gets the formatter arguments that the formatter implementation will parse. Can be null. - /// - /// - /// The formatter arguments. - /// - public string FormatterArguments { get; private set; } + #region Public Properties - /// - /// Gets the name of the formatter to use . e.g. 'select', 'plural'. Can be null. - /// - /// - /// The name of the formatter. - /// - public string FormatterName { get; private set; } + /// + /// Gets the formatter arguments that the formatter implementation will parse. Can be null. + /// + /// + /// The formatter arguments. + /// + public string? FormatterArguments { get; } - /// - /// Gets the source literal. - /// - /// - /// The source literal. - /// - public Literal SourceLiteral { get; private set; } + /// + /// Gets the name of the formatter to use . e.g. 'select', 'plural'. Can be null. + /// + /// + /// The name of the formatter. + /// + public string? FormatterName { get; } - /// - /// Gets the variable name. Never null. - /// - /// - /// The variable. - /// - public string Variable { get; private set; } + /// + /// Gets the source literal. + /// + /// + /// The source literal. + /// + public Literal SourceLiteral { get; } - #endregion + /// + /// Gets the variable name. Never null. + /// + /// + /// The variable. + /// + public string Variable { get; } - #region Public Methods and Operators + #endregion - /// - /// Clones this instance. - /// - /// - /// The . - /// - public FormatterRequest Clone() - { - return new FormatterRequest( - this.SourceLiteral.Clone(), - this.Variable, - this.FormatterName, - this.FormatterArguments); - } + #region Public Methods and Operators - #endregion + /// + /// Clones this instance. + /// + /// + /// The . + /// + public FormatterRequest Clone() + { + return new FormatterRequest( + this.SourceLiteral.Clone(), + this.Variable, + this.FormatterName, + this.FormatterArguments); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/BaseValueFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/BaseValueFormatter.cs new file mode 100644 index 0000000..950202b --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/BaseValueFormatter.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Base formatter for values such as numbers, dates, times, etc. +/// +public abstract class BaseValueFormatter : BaseFormatter, IFormatter +{ + /// + /// Initializes a new instance of the class. + /// + protected BaseValueFormatter() + { + } + + /// + [ExcludeFromCodeCoverage] + public bool VariableMustExist => true; + + /// + public abstract bool CanFormat(FormatterRequest request); + + /// + /// Formats the value using the given style. + /// + /// + /// + /// + /// + /// + /// + protected abstract string FormatValue(CultureInfo culture, CustomValueFormatter? customValueFormatter, + string variable, string style, object? value); + + /// + public string Format( + CultureInfo culture, + FormatterRequest request, + IReadOnlyDictionary args, + object? value, + IMessageFormatter messageFormatter) + { + var formatterArgs = request.FormatterArguments!; + return FormatValue( + culture: culture, + customValueFormatter: messageFormatter.CustomValueFormatter, + variable: request.Variable, + style: formatterArgs, + value: value); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/DateFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/DateFormatter.cs new file mode 100644 index 0000000..b053de7 --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/DateFormatter.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Formatter for dates. +/// +public class DateFormatter : BaseValueFormatter, IFormatter +{ + /// + /// Name of this formatter. + /// + private const string FormatterName = "date"; + + /// + public override bool CanFormat(FormatterRequest request) => + request.FormatterName == FormatterName; + + /// + protected override string FormatValue( + CultureInfo culture, + CustomValueFormatter? customValueFormatter, + string variable, + string style, + object? value) + { + if (customValueFormatter?.TryFormatDate(culture, value, style, out var formatted) == true) + { + // When the formatter returns `true`, the string will be set. + return formatted!; + } + + return style switch + { + "" or "short" => string.Format(culture, "{0:d}", value), + "full" => string.Format(culture, "{0:D}", value), + _ => throw new UnsupportedFormatStyleException( + variable: variable, + format: FormatterName, + style: style) + }; + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/NumberFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/NumberFormatter.cs new file mode 100644 index 0000000..aef1e10 --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/NumberFormatter.cs @@ -0,0 +1,61 @@ +using System; +using System.Globalization; + +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Formatter for numbers. +/// +public class NumberFormatter : BaseValueFormatter, IFormatter +{ + /// + /// Name of this formatter. + /// + private const string FormatterName = "number"; + + /// + public override bool CanFormat(FormatterRequest request) => + request.FormatterName == FormatterName; + + /// + protected override string FormatValue( + CultureInfo culture, + CustomValueFormatter? customValueFormatter, + string variable, + string style, + object? value) + { + if (customValueFormatter?.TryFormatNumber(culture, value, style, out var formatted) == true) + { + // When the formatter returns `true`, the string will be set. + return formatted!; + } + + return style switch + { + "" => string.Format(culture, "{0:#,##0.###}", value), + "integer" => FormatInteger(culture, value), + "currency" => string.Format(culture, "{0:C}", value), + "percent" => string.Format(culture, "{0:P0}", value), + _ => throw new UnsupportedFormatStyleException( + variable: variable, + format: FormatterName, + style: style) + }; + } + + /// + /// Attempts to format as an integer by first converting the value to + /// an integer. Otherwise prints the value as-is. + /// + /// + /// + /// + private static string FormatInteger(IFormatProvider cultureInfo, object? value) => + value switch + { + decimal or float or double => string.Format(cultureInfo, "{0}", Convert.ToInt64(value)), + string s => decimal.TryParse(s, NumberStyles.Any, cultureInfo, out var parsed) ? FormatInteger(cultureInfo, parsed) : s, + _ => string.Format(cultureInfo, "{0}", value) + }; +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs new file mode 100644 index 0000000..1c60f70 --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs @@ -0,0 +1,153 @@ +using System; +using System.Globalization; + +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Represents the 'operations' for a given source number, as defined by Unicode TR35/LDML. +/// +internal readonly struct PluralContext +{ + public PluralContext(int number) + { + Number = number; + N = Math.Abs(number); + I = number; + V = 0; + W = 0; + F = 0; + T = 0; + C = 0; + E = 0; + } + + public PluralContext(decimal number) : this(number.ToString(CultureInfo.InvariantCulture), (double) number) + { + } + + public PluralContext(double number) : this(number.ToString(CultureInfo.InvariantCulture), number) + { + } + + /// + /// Represents operands for a source number in string format. + /// This library treats the input as a stringified double and does not currently parse out + /// compact decimal forms (e.g., "1.25c4"). + /// + public PluralContext(string number) : this(number, double.Parse(number, CultureInfo.InvariantCulture)) + { + } + + /// + /// Common constructor for parsing out operands from a stringified number. + /// + /// + /// The values of , , , and are all derived + /// from the fractional part of the number, so it's important be parsable as a number. + /// + /// The number in string form, as a decimal (not scientific/compact form). + /// The number pre-parsed as a double. + private PluralContext(string number, double parsed) + { + Number = parsed; + N = Math.Abs(parsed); + I = (int) parsed; + + var dotIndex = number.IndexOf('.'); + if (dotIndex == -1) + { + V = 0; + W = 0; + F = 0; + T = 0; + C = 0; + E = 0; + } + else + { +#if NET5_0_OR_GREATER + var fractionSpan = number.AsSpan(dotIndex + 1, number.Length - dotIndex - 1); + var fractionSpanWithoutZeroes = fractionSpan.TrimEnd('0'); +#else + var fractionSpan = number.Substring(dotIndex + 1, number.Length - dotIndex - 1); + var fractionSpanWithoutZeroes = fractionSpan.TrimEnd('0'); +#endif + + V = fractionSpan.Length; + W = fractionSpanWithoutZeroes.Length; + F = int.Parse(fractionSpan); + T = int.Parse(fractionSpanWithoutZeroes); + + // The compact decimal exponent representations are not used in this library as operands are + // always assumed to be parsable numbers. + C = 0; + E = 0; + } + } + + /// + /// The 'source number' being evaluated for pluralization. + /// + public double Number { get; } + + /// + /// The absolute value of . + /// + public double N { get; } + + /// + /// The integer digits of . + /// + /// + /// 22.6 -> I = 22 + /// + public int I { get; } + + /// + /// The count of visible fraction digits of , with trailing zeroes. + /// + /// + /// 1.450 -> V = 3 + /// + public int V { get; } + + /// + /// The count of visible fraction digits of , without trailing zeroes. + /// + /// + /// 1.450 -> W = 2 + /// + public int W { get; } + + /// + /// The visible fraction digits of , with trailing zeroes, as an integer. + /// + /// + /// 1.450 -> F = 450 + /// + public int F { get; } + + /// + /// The visible fraction digits of , without trailing zeroes, as an integer. + /// + /// + /// 1.450 -> T = 45 + /// + public int T { get; } + + /// + /// The compact decimal exponent of , in such cases where + /// is represented as "[x]cC" such that == x * 10^C. + /// + /// + /// 1.25c4 -> C = 4 + /// 125c2 -> C = 2 + /// 12500 -> C = 0, as the number is not represented in compact decimal form. + /// + public int C { get; } + + /// + /// Deprecated (in LDML) synonym for , reserved for future use by the standard. + /// + public int E { get; } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index e69ffc5..a2bf1b5 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -3,186 +3,282 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. +using Jeffijoe.MessageFormat.Helpers; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; -using System.Text; -namespace Jeffijoe.MessageFormat.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Plural Formatter +/// +public class PluralFormatter : BaseFormatter, IFormatter { /// - /// Plural Formatter + /// ICU MessageFormat function name for "default" pluralization, based on cardinal numbers. + /// + internal const string PluralFunction = "plural"; + + /// + /// ICU MessageFormat function name for ordinal pluralization. + /// + internal const string OrdinalFunction = "selectordinal"; + + /// + /// Delegate type to try to look up a specific plural rule for a given locale. + /// + internal delegate bool TryGetRuleForLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer); + + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. /// - public class PluralFormatter : BaseFormatter, IFormatter + public PluralFormatter() { - #region Constructors and Destructors + this.CardinalPluralizers = new Dictionary(); + this.OrdinalPluralizers = new Dictionary(); + } + + #endregion + + #region Public Properties + + /// + /// This formatter requires the input variable to exist. + /// + public bool VariableMustExist => true; - /// - /// Initializes a new instance of the class. - /// - public PluralFormatter() + /// + /// Gets the pluralizers dictionary to use for cardinal numbers. Key is the locale. + /// + /// + /// The pluralizers. + /// + public IDictionary CardinalPluralizers { get; } + + /// + /// Gets the pluralizers dictionary to use for ordinal numbers. Key is the locale. + /// + /// + /// The ordinal pluralizers. + /// + public IDictionary OrdinalPluralizers { get; } + + #endregion + + #region Public Methods and Operators + + /// + /// Determines whether this instance can format a message based on the specified parameters. + /// + /// + /// The parameters. + /// + /// + /// The . + /// + public bool CanFormat(FormatterRequest request) + { + if (request.FormatterName is null) { - this.Pluralizers = new Dictionary(); - this.AddStandardPluralizers(); + return false; } - #endregion + return request.FormatterName == PluralFunction || request.FormatterName == OrdinalFunction; + } - #region Public Properties + /// + /// Using the specified parameters and arguments, a formatted string shall be returned. + /// The is being provided as well, to enable + /// nested formatting. This is only called if returns true. + /// The args will always contain the . + /// + /// + /// The culture being used. It is up to the formatter what they do with this information. + /// + /// + /// The parameters. + /// + /// + /// The arguments. + /// + /// The value of from the given args dictionary. Can be null. + /// + /// The message formatter. + /// + /// + /// The . + /// + /// + /// If does not specify a formatter name supported by . + /// + public string Format(CultureInfo culture, + FormatterRequest request, + IReadOnlyDictionary args, + object? value, + IMessageFormatter messageFormatter) + { + var arguments = this.ParseArguments(request); + double offset = 0; + var offsetExtension = arguments.Extensions.FirstOrDefault(x => x.Extension == "offset"); + if (offsetExtension != null) + { + offset = Convert.ToDouble(offsetExtension.Value); + } + + // Get CLDR plural ruleset from request. + // CanFormat() should have guaranteed this is valid, but we'll be defensive just in case. + TryGetRuleForLocale cldrPluralLookup; + IDictionary customLookup; + if (request.FormatterName == PluralFunction) + { + cldrPluralLookup = PluralRulesMetadata.TryGetCardinalRuleByLocale; + customLookup = this.CardinalPluralizers; + } + else if (request.FormatterName == OrdinalFunction) + { + cldrPluralLookup = PluralRulesMetadata.TryGetOrdinalRuleByLocale; + customLookup = this.OrdinalPluralizers; + } + else + { + throw new MessageFormatterException($"Unsupported plural formatter name: {request.FormatterName}"); + } - /// - /// Gets the pluralizers dictionary. Key is the locale. - /// - /// - /// The pluralizers. - /// - public IDictionary Pluralizers { get; private set; } + var locale = culture.Name; + var ctx = CreatePluralContext(value, offset); + var pluralized = this.Pluralize( + locale, + cldrPluralLookup, + customLookup, + arguments, + ctx, + offset); + var result = this.ReplaceNumberLiterals(pluralized, ctx.Number); + var formatted = messageFormatter.FormatMessage(result, args, culture); + return formatted; + } - #endregion + #endregion - #region Public Methods and Operators + #region Methods - /// - /// Determines whether this instance can format a message based on the specified parameters. - /// - /// - /// The parameters. - /// - /// - /// The . - /// - public bool CanFormat(FormatterRequest request) + /// + /// Returns the correct plural block. + /// + /// + /// The locale. + /// + /// + /// Delegate to retrieve a for a given locale. + /// + /// + /// Dictionary to retrieve a for a given locale, to be evaluated + /// before resolving against . + /// + /// + /// The parsed arguments string. + /// + /// + /// The plural context. + /// + /// + /// The offset (already applied in context). + /// + /// + /// The . + /// + /// + /// The 'other' option was not found in pattern, or is missing + /// both the provided locale and the CLDR root locale. + /// + [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1126:PrefixCallsCorrectly", + Justification = "Reviewed. Suppression is OK here.")] + internal string Pluralize( + string locale, + TryGetRuleForLocale cldrPluralLookup, + IDictionary customLookup, + ParsedArguments arguments, + PluralContext context, + double offset) + { + string? pluralForm = null; + if (customLookup.TryGetValue(locale, out var pluralizer)) { - return request.FormatterName == "plural"; + pluralForm = pluralizer(context.Number); } - - /// - /// Using the specified parameters and arguments, a formatted string shall be returned. - /// The is being provided as well, to enable - /// nested formatting. This is only called if returns true. - /// The args will always contain the . - /// - /// - /// The locale being used. It is up to the formatter what they do with this information. - /// - /// - /// The parameters. - /// - /// - /// The arguments. - /// - /// The value of from the given args dictionary. Can be null. - /// - /// The message formatter. - /// - /// - /// The . - /// - public string Format( - string locale, - FormatterRequest request, - IDictionary args, - object value, - IMessageFormatter messageFormatter) + else { - var arguments = this.ParseArguments(request); - double offset = 0; - var offsetExtension = arguments.Extensions.FirstOrDefault(x => x.Extension == "offset"); - if (offsetExtension != null) + foreach (var candidate in LocaleHelper.GetInheritanceChain(locale)) { - offset = Convert.ToDouble(offsetExtension.Value); + if (cldrPluralLookup(candidate, out var contextPluralizer)) + { + pluralForm = contextPluralizer(context); + break; + } } + } - var n = Convert.ToDouble(value); - var pluralized = new StringBuilder(this.Pluralize(locale, arguments, n, offset)); - var result = this.ReplaceNumberLiterals(pluralized, n - offset); - var formatted = messageFormatter.FormatMessage(result, args); - return formatted; + if (pluralForm is null) + { + // GetInheritanceChain should resolve the root CLDR locale as a last attempt, so this should never happen... + throw new MessageFormatterException($"Could not find locale {locale} in specified plural rule lookup"); } - #endregion - - #region Methods - - /// - /// Returns the correct plural block. - /// - /// - /// The locale. - /// - /// - /// The parsed arguments string. - /// - /// - /// The n. - /// - /// - /// The offset. - /// - /// - /// The . - /// - /// - /// The 'other' option was not found in pattern. - /// - [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1126:PrefixCallsCorrectly", - Justification = "Reviewed. Suppression is OK here.")] - internal string Pluralize(string locale, ParsedArguments arguments, double n, double offset) + KeyedBlock? other = null; + foreach (var keyedBlock in arguments.KeyedBlocks) { - Pluralizer pluralizer; - if (this.Pluralizers.TryGetValue(locale, out pluralizer) == false) + if (keyedBlock.Key == OtherKey) { - pluralizer = this.Pluralizers["en"]; + other = keyedBlock; } - var pluralForm = pluralizer(n - offset); - KeyedBlock other = null; - foreach (var keyedBlock in arguments.KeyedBlocks) + if (keyedBlock.Key.StartsWith("=")) { - if (keyedBlock.Key == OtherKey) - { - other = keyedBlock; - } - - if (keyedBlock.Key.StartsWith("=")) - { - var numberLiteral = Convert.ToDouble(keyedBlock.Key.Substring(1)); + var numberLiteral = Convert.ToDouble(keyedBlock.Key.Substring(1)); - // ReSharper disable once CompareOfFloatsByEqualityOperator - if (numberLiteral == n) - { - return keyedBlock.BlockText; - } - } - - if (keyedBlock.Key == pluralForm) + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (numberLiteral == context.Number + offset) { return keyedBlock.BlockText; } } - if (other == null) + if (keyedBlock.Key == pluralForm) { - throw new MessageFormatterException("'other' option not found in pattern."); + return keyedBlock.BlockText; } + } - return other.BlockText; + if (other == null) + { + throw new MessageFormatterException("'other' option not found in pattern."); } - /// - /// Replaces the number literals with the actual number. - /// - /// - /// The pluralized. - /// - /// - /// The n. - /// - /// - /// The . - /// - internal string ReplaceNumberLiterals(StringBuilder pluralized, double n) + return other.BlockText; + } + + /// + /// Replaces the number literals with the actual number. + /// + /// + /// The pluralized. + /// + /// + /// The n. + /// + /// + /// The . + /// + internal string ReplaceNumberLiterals(string pluralized, double n) + { + var sb = StringBuilderPool.Get(); + + try { // I've done this a few times now.. const char OpenBrace = '{'; @@ -191,13 +287,13 @@ internal string ReplaceNumberLiterals(StringBuilder pluralized, double n) const char EscapeChar = '\''; var braceBalance = 0; var insideEscapeSequence = false; - var sb = new StringBuilder(); for (int i = 0; i < pluralized.Length; i++) { var c = pluralized[i]; if (c == EscapeChar) { + // Append it anyway because the escae sb.Append(EscapeChar); if (i == pluralized.Length - 1) @@ -207,6 +303,7 @@ internal string ReplaceNumberLiterals(StringBuilder pluralized, double n) { insideEscapeSequence = false; } + continue; } @@ -224,12 +321,11 @@ internal string ReplaceNumberLiterals(StringBuilder pluralized, double n) continue; } - if (nextChar == '{' || nextChar == '}' || nextChar == '#') + if (nextChar is '{' or '}' or '#') { sb.Append(nextChar); insideEscapeSequence = true; ++i; - continue; } continue; @@ -263,31 +359,43 @@ internal string ReplaceNumberLiterals(StringBuilder pluralized, double n) return sb.ToString(); } + finally + { + StringBuilderPool.Return(sb); + } + } - /// - /// Adds the standard pluralizers. - /// - private void AddStandardPluralizers() + /// + /// Creates a for the specified value. + /// + /// + /// + /// + [ExcludeFromCodeCoverage] + private static PluralContext CreatePluralContext(object? value, double offset) + { + if (offset == 0) { - this.Pluralizers.Add( - "en", - n => { - // ReSharper disable CompareOfFloatsByEqualityOperator - if (n == 0) - { - return "zero"; - } + if (value is string v) + { + return new PluralContext(v); + } - if (n == 1) - { - return "one"; - } + if (value is int i) + { + return new PluralContext(i); + } - // ReSharper restore CompareOfFloatsByEqualityOperator - return "other"; - }); + if (value is decimal d) + { + return new PluralContext(d); + } + + return new PluralContext(Convert.ToDouble(value)); } - #endregion + return new PluralContext(Convert.ToDouble(value) - offset); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs new file mode 100644 index 0000000..ee9cab0 --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs @@ -0,0 +1,9 @@ +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +internal static partial class PluralRulesMetadata +{ + public static partial bool TryGetCardinalRuleByLocale(string locale, out ContextPluralizer? contextPluralizer); + + public static partial bool TryGetOrdinalRuleByLocale(string locale, out ContextPluralizer? contextPluralizer); +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/Pluralizer.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/Pluralizer.cs index 16e1eff..f371439 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/Pluralizer.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/Pluralizer.cs @@ -2,12 +2,18 @@ // - Pluralizer.cs // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -namespace Jeffijoe.MessageFormat.Formatting.Formatters -{ - /// - /// Given the specified number, determines what plural form is being used. - /// - /// The number used to determine the pluralization rule.. - /// The plural form to use. - public delegate string Pluralizer(double n); -} \ No newline at end of file +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Given the specified number, determines what plural form is being used. +/// +/// The number used to determine the pluralization rule.. +/// The plural form to use. +public delegate string Pluralizer(double n); + +/// +/// Given the specified number context, determines what plural form is being used. +/// +/// The context of the number used to determine the pluralization rule.. +/// The plural form to use. +internal delegate string ContextPluralizer(PluralContext context); \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/SelectFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/SelectFormatter.cs index 4074439..0a5492c 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/SelectFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/SelectFormatter.cs @@ -6,79 +6,90 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; -namespace Jeffijoe.MessageFormat.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Implementation of the SelectFormat. +/// +public class SelectFormatter : BaseFormatter, IFormatter { + #region Public Properties + /// - /// Implementation of the SelectFormat. + /// This formatter requires the input variable to exist. /// - public class SelectFormatter : BaseFormatter, IFormatter - { - #region Public Methods and Operators + [ExcludeFromCodeCoverage] + public bool VariableMustExist => true; + + #endregion + + + #region Public Methods and Operators + - /// - /// Determines whether this instance can format a message based on the specified parameters. - /// - /// - /// The parameters. - /// - /// - /// The . - /// - public bool CanFormat(FormatterRequest request) - { - return request.FormatterName == "select"; - } + /// + /// Determines whether this instance can format a message based on the specified parameters. + /// + /// + /// The parameters. + /// + /// + /// The . + /// + public bool CanFormat(FormatterRequest request) + { + return request.FormatterName == "select"; + } - /// - /// Using the specified parameters and arguments, a formatted string shall be returned. - /// The is being provided as well, to enable - /// nested formatting. This is only called if returns true. - /// The argswill always contain the . - /// - /// The locale being used. It is up to the formatter what they do with this information. - /// The parameters. - /// The arguments. - /// The value of from the given args dictionary. Can be null. - /// The message formatter. - /// - /// The . - /// - /// 'other' option not found in pattern, and variable was not present in collection. - [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1126:PrefixCallsCorrectly", - Justification = "Reviewed. Suppression is OK here.")] - public string Format( - string locale, - FormatterRequest request, - IDictionary args, - object value, - IMessageFormatter messageFormatter) + /// + /// Using the specified parameters and arguments, a formatted string shall be returned. + /// The is being provided as well, to enable + /// nested formatting. This is only called if returns true. + /// The args will always contain the . + /// + /// The culture being used. It is up to the formatter what they do with this information. + /// The parameters. + /// The arguments. + /// The value of from the given args dictionary. Can be null. + /// The message formatter. + /// + /// The . + /// + /// 'other' option not found in pattern, and variable was not present in collection. + [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1126:PrefixCallsCorrectly", + Justification = "Reviewed. Suppression is OK here.")] + public string Format(CultureInfo culture, + FormatterRequest request, + IReadOnlyDictionary args, + object? value, + IMessageFormatter messageFormatter) + { + var str = Convert.ToString(value); + var parsed = this.ParseArguments(request); + KeyedBlock? other = null; + foreach (var keyedBlock in parsed.KeyedBlocks) { - var parsed = this.ParseArguments(request); - KeyedBlock other = null; - foreach (var keyedBlock in parsed.KeyedBlocks) + if (str == keyedBlock.Key) { - var str = Convert.ToString(value); - if (str == keyedBlock.Key) - { - return messageFormatter.FormatMessage(keyedBlock.BlockText, args); - } - - if (keyedBlock.Key == OtherKey) - { - other = keyedBlock; - } + return messageFormatter.FormatMessage(keyedBlock.BlockText, args, culture); } - if (other == null) + if (keyedBlock.Key == OtherKey) { - throw new MessageFormatterException( - "'other' option not found in pattern, and variable was not present in collection."); + other = keyedBlock; } + } - return messageFormatter.FormatMessage(other.BlockText, args); + if (other == null) + { + throw new MessageFormatterException( + "'other' option not found in pattern, and variable was not present in collection."); } - #endregion + return messageFormatter.FormatMessage(other.BlockText, args, culture); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/TimeFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/TimeFormatter.cs new file mode 100644 index 0000000..be6aa8a --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/TimeFormatter.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Formatter for times. +/// +public class TimeFormatter : BaseValueFormatter, IFormatter +{ + /// + /// Name of this formatter. + /// + private const string FormatterName = "time"; + + /// + public override bool CanFormat(FormatterRequest request) => + request.FormatterName == FormatterName; + + /// + protected override string FormatValue( + CultureInfo culture, + CustomValueFormatter? customValueFormatter, + string variable, + string style, + object? value) + { + if (customValueFormatter?.TryFormatTime(culture, value, style, out var formatted) == true) + { + // When the formatter returns `true`, the string will be set. + return formatted!; + } + + return style switch + { + "" or "medium" => string.Format(culture, "{0:T}", value), + "short" => string.Format(culture, "{0:t}", value), + _ => throw new UnsupportedFormatStyleException( + variable: variable, + format: FormatterName, + style: style) + }; + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/VariableFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/VariableFormatter.cs index 37504e5..fbf2d21 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/VariableFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/VariableFormatter.cs @@ -1,90 +1,72 @@ -// MessageFormat for .NET +// MessageFormat for .NET // - VariableFormatter.cs // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -namespace Jeffijoe.MessageFormat.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Simple variable replacer. +/// +public class VariableFormatter : IFormatter { + #region Public Properties + /// - /// Simple variable replacer. + /// This formatter requires the input variable to exist. /// - public class VariableFormatter : IFormatter - { - #region Fields + public bool VariableMustExist => true; - private ConcurrentDictionary cultures = new ConcurrentDictionary(); + #endregion - #endregion + #region Public Methods and Operators - #region Public Methods and Operators - - /// - /// Determines whether this instance can format a message based on the specified parameters. - /// - /// - /// The parameters. - /// - /// - /// The . - /// - public bool CanFormat(FormatterRequest request) - { - return request.FormatterName == null; - } - - /// - /// Using the specified parameters and arguments, a formatted string shall be returned. - /// The is being provided as well, to enable - /// nested formatting. This is only called if returns true. - /// The args will always contain the . - /// - /// The locale being used. It is up to the formatter what they do with this information. - /// The parameters. - /// The arguments. - /// The value of from the given args dictionary. Can be null. - /// The message formatter. - /// - /// The . - /// - public string Format( - string locale, - FormatterRequest request, - IDictionary args, - object value, - IMessageFormatter messageFormatter) - { - switch (value) - { - case null: - return string.Empty; - case IFormattable formattable: - return formattable.ToString(null, GetCultureInfo(locale)); - default: - return value.ToString(); - } - } + /// + /// Determines whether this instance can format a message based on the specified parameters. + /// + /// + /// The parameters. + /// + /// + /// The . + /// + public bool CanFormat(FormatterRequest request) + { + return request.FormatterName == null; + } - /// - /// Get and cache the culture for a locale. - /// - /// Locale for which to get the culture. - /// - /// Culture of locale. - /// - private CultureInfo GetCultureInfo(string locale) + /// + /// Using the specified parameters and arguments, a formatted string shall be returned. + /// The is being provided as well, to enable + /// nested formatting. This is only called if returns true. + /// The args will always contain the . + /// + /// The culture being used. It is up to the formatter what they do with this information. + /// The parameters. + /// The arguments. + /// The value of from the given args dictionary. Can be null. + /// The message formatter. + /// + /// The . + /// + public string Format(CultureInfo culture, + FormatterRequest request, + IReadOnlyDictionary args, + object? value, + IMessageFormatter messageFormatter) + { + switch (value) { - if (!this.cultures.ContainsKey(locale)) - { - this.cultures[locale] = new CultureInfo(locale); - } - return this.cultures[locale]; + case IFormattable formattable: + return formattable.ToString(null, culture); + default: + return value?.ToString() ?? string.Empty; } - - #endregion } -} \ No newline at end of file + + #endregion +} diff --git a/src/Jeffijoe.MessageFormat/Formatting/IFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/IFormatter.cs index e5bae52..88e0327 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/IFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/IFormatter.cs @@ -4,48 +4,58 @@ // Copyright (C) Jeff Hansen 2014. All rights reserved. using System.Collections.Generic; +using System.Globalization; -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// A Formatter is what transforms a pattern into a string, using the proper arguments. +/// +public interface IFormatter { + #region Public Properties + /// - /// A Formatter is what transforms a pattern into a string, using the proper arguments. + /// Each Formatter must declare whether or not an input variable is required to exist. + /// Most of the time that is the case. /// - public interface IFormatter - { - #region Public Methods and Operators - - /// - /// Determines whether this instance can format a message based on the specified parameters. - /// - /// - /// The parameters. - /// - /// - /// The . - /// - bool CanFormat(FormatterRequest request); - - /// - /// Using the specified parameters and arguments, a formatted string shall be returned. - /// The is being provided as well, to enable - /// nested formatting. This is only called if returns true. - /// The args will always contain the . - /// - /// The locale being used. It is up to the formatter what they do with this information. - /// The parameters. - /// The arguments. - /// The value of from the given args dictionary. Can be null. - /// The message formatter. - /// - /// The . - /// - string Format( - string locale, - FormatterRequest request, - IDictionary args, - object value, - IMessageFormatter messageFormatter); - - #endregion - } + bool VariableMustExist { get; } + + #endregion + + #region Public Methods and Operators + + /// + /// Determines whether this instance can format a message based on the specified parameters. + /// + /// + /// The parameters. + /// + /// + /// The . + /// + bool CanFormat(FormatterRequest request); + + /// + /// Using the specified parameters and arguments, a formatted string shall be returned. + /// The is being provided as well, to enable + /// nested formatting. This is only called if returns true. + /// The args will always contain the . + /// + /// The culture being used. It is up to the formatter what they do with this information. + /// The parameters. + /// The arguments. + /// The value of from the given args dictionary. Can be null. + /// The message formatter. + /// + /// The . + /// + string Format( + CultureInfo culture, + FormatterRequest request, + IReadOnlyDictionary args, + object? value, + IMessageFormatter messageFormatter); + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/IFormatterLibrary.cs b/src/Jeffijoe.MessageFormat/Formatting/IFormatterLibrary.cs index 82ee28c..94d22b0 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/IFormatterLibrary.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/IFormatterLibrary.cs @@ -5,26 +5,25 @@ using System.Collections.Generic; -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Manages formatters to use. +/// +public interface IFormatterLibrary : IList { + #region Public Methods and Operators + /// - /// Manages formatters to use. + /// Gets the formatter to use. If none was found, throws an exception. /// - public interface IFormatterLibrary : IList - { - #region Public Methods and Operators - - /// - /// Gets the formatter to use. If none was found, throws an exception. - /// - /// - /// The request. - /// - /// - /// The . - /// - IFormatter GetFormatter(FormatterRequest request); + /// + /// The request. + /// + /// + /// The . + /// + IFormatter GetFormatter(FormatterRequest request); - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/KeyedBlock.cs b/src/Jeffijoe.MessageFormat/Formatting/KeyedBlock.cs index e239d87..f56e6d2 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/KeyedBlock.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/KeyedBlock.cs @@ -2,52 +2,51 @@ // - KeyedBlock.cs // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// A keyed block contains a key and a block +/// containing the text that the formatter will return +/// when the block is being used. +/// +public class KeyedBlock { + #region Constructors and Destructors + /// - /// A keyed block contains a key and a block - /// containing the text that the formatter will return - /// when the block is being used. + /// Initializes a new instance of the class. /// - public class KeyedBlock + /// + /// The key. + /// + /// + /// The block text. + /// + public KeyedBlock(string key, string blockText) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The key. - /// - /// - /// The block text. - /// - public KeyedBlock(string key, string blockText) - { - this.Key = key; - this.BlockText = blockText; - } + this.Key = key; + this.BlockText = blockText; + } - #endregion + #endregion - #region Public Properties + #region Public Properties - /// - /// Gets the block text to be returned by the formatter. - /// - /// - /// The block text. - /// - public string BlockText { get; private set; } + /// + /// Gets the block text to be returned by the formatter. + /// + /// + /// The block text. + /// + public string BlockText { get; private set; } - /// - /// Gets the key used by the formatter to make decisions. - /// - /// - /// The key. - /// - public string Key { get; private set; } + /// + /// Gets the key used by the formatter to make decisions. + /// + /// + /// The key. + /// + public string Key { get; private set; } - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/ParsedArguments.cs b/src/Jeffijoe.MessageFormat/Formatting/ParsedArguments.cs index 1955d8d..0fee45d 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/ParsedArguments.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/ParsedArguments.cs @@ -3,64 +3,52 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Container class for formatter argument parsing result. +/// +public class ParsedArguments { + #region Constructors and Destructors + /// - /// Container class for formatter argument parsing result. + /// Initializes a new instance of the class. /// - public class ParsedArguments + /// + /// The keyed Blocks. + /// + /// + /// The extensions. + /// + public ParsedArguments(IEnumerable keyedBlocks, IEnumerable extensions) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The keyed Blocks. - /// - /// - /// The extensions. - /// - public ParsedArguments(IEnumerable keyedBlocks, IEnumerable extensions) - { - if (keyedBlocks == null) - { - throw new ArgumentNullException("keyedBlocks"); - } - - if (extensions == null) - { - throw new ArgumentNullException("extensions"); - } - - this.KeyedBlocks = keyedBlocks.ToList(); - this.Extensions = extensions.ToList(); - } + this.KeyedBlocks = keyedBlocks.ToList(); + this.Extensions = extensions.ToList(); + } - #endregion + #endregion - #region Public Properties + #region Public Properties - /// - /// Gets the extensions. - /// - /// - /// The extensions. - /// - public IEnumerable Extensions { get; private set; } + /// + /// Gets the extensions. + /// + /// + /// The extensions. + /// + public IEnumerable Extensions { get; private set; } - /// - /// Gets the keyed blocks. - /// - /// - /// The keyed blocks. - /// - public IEnumerable KeyedBlocks { get; private set; } + /// + /// Gets the keyed blocks. + /// + /// + /// The keyed blocks. + /// + public IEnumerable KeyedBlocks { get; private set; } - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/UnsupportedFormatStyleException.cs b/src/Jeffijoe.MessageFormat/Formatting/UnsupportedFormatStyleException.cs new file mode 100644 index 0000000..1e0a0ac --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/UnsupportedFormatStyleException.cs @@ -0,0 +1,87 @@ +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Thrown when formatter is unable to apply the given style. +/// +public class UnsupportedFormatStyleException : MessageFormatterException +{ + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + /// + /// The variable. + /// + /// + /// The format. + /// + /// + /// The style that was not supported. + /// + public UnsupportedFormatStyleException( + string variable, + string format, + string style) + : base(BuildMessage(variable, format, style)) + { + this.Variable = variable; + this.Format = format; + this.Style = style; + } + + #endregion + + #region Public Properties + + /// + /// Gets the name of the missing variable. + /// + /// + /// The missing variable. + /// + public string Variable { get; private set; } + + /// + /// Gets the format that attempted to apply the style. + /// + /// + /// The format. + /// + public string Format { get; private set; } + + /// + /// Gets the style that could not be applied. + /// + /// + /// The style. + /// + public string Style { get; private set; } + + #endregion + + #region Methods + + /// + /// Builds the message. + /// + /// + /// The variable. + /// + /// + /// The format. + /// + /// + /// The style that was not supported. + /// + /// + /// The . + /// + private static string BuildMessage( + string variable, + string format, + string style) => + $"The variable '{variable}' could not be formatted as a '{format}' because the style '{style}' is not supported."; + + #endregion +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/VariableNotFoundException.cs b/src/Jeffijoe.MessageFormat/Formatting/VariableNotFoundException.cs index 10b66c8..a68b210 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/VariableNotFoundException.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/VariableNotFoundException.cs @@ -3,57 +3,56 @@ // // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2015. All rights reserved. -namespace Jeffijoe.MessageFormat.Formatting +namespace Jeffijoe.MessageFormat.Formatting; + +/// +/// Thrown when a variable declared in a pattern was non-existent. +/// +public class VariableNotFoundException : MessageFormatterException { + #region Constructors and Destructors + /// - /// Thrown when a variable declared in a pattern was non-existent. + /// Initializes a new instance of the class. /// - public class VariableNotFoundException : MessageFormatterException + /// + /// The variable. + /// + public VariableNotFoundException(string missingVariable) + : base(BuildMessage(missingVariable)) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The variable. - /// - public VariableNotFoundException(string missingVariable) - : base(BuildMessage(missingVariable)) - { - this.MissingVariable = missingVariable; - } - - #endregion - - #region Public Properties - - /// - /// Gets the name of the missing variable. - /// - /// - /// The missing variable. - /// - public string MissingVariable { get; private set; } - - #endregion - - #region Methods - - /// - /// Builds the message. - /// - /// - /// The variable. - /// - /// - /// The . - /// - private static string BuildMessage(string variable) - { - return string.Format("The variable '{0}' was not found in the arguments collection.", variable); - } - - #endregion + this.MissingVariable = missingVariable; } + + #endregion + + #region Public Properties + + /// + /// Gets the name of the missing variable. + /// + /// + /// The missing variable. + /// + public string MissingVariable { get; private set; } + + #endregion + + #region Methods + + /// + /// Builds the message. + /// + /// + /// The variable. + /// + /// + /// The . + /// + private static string BuildMessage(string variable) + { + return string.Format("The variable '{0}' was not found in the arguments collection.", variable); + } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Helpers/CharHelper.cs b/src/Jeffijoe.MessageFormat/Helpers/CharHelper.cs index cc6d8e3..6532324 100644 --- a/src/Jeffijoe.MessageFormat/Helpers/CharHelper.cs +++ b/src/Jeffijoe.MessageFormat/Helpers/CharHelper.cs @@ -2,47 +2,46 @@ // - CharHelper.cs // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -namespace Jeffijoe.MessageFormat.Helpers +namespace Jeffijoe.MessageFormat.Helpers; + +/// +/// Char helper +/// +internal static class CharHelper { + #region Static Fields + /// - /// Char helper + /// The alphanumberic. /// - internal static class CharHelper - { - #region Static Fields + private static readonly char[] Alphanumberic = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".ToCharArray(); - /// - /// The alphanumberic. - /// - private static readonly char[] Alphanumberic = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".ToCharArray(); + #endregion - #endregion + #region Methods - #region Methods - - /// - /// Determines whether the specified character is alpha numeric. - /// - /// - /// The c. - /// - /// - /// The . - /// - internal static bool IsAlphaNumeric(this char c) + /// + /// Determines whether the specified character is alpha numeric. + /// + /// + /// The c. + /// + /// + /// The . + /// + internal static bool IsAlphaNumeric(this char c) + { + foreach (var chr in Alphanumberic) { - foreach (var chr in Alphanumberic) + if (chr == c) { - if (chr == c) - { - return true; - } + return true; } - - return false; } - #endregion + return false; } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs b/src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs new file mode 100644 index 0000000..a5a31cc --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs @@ -0,0 +1,57 @@ +using Jeffijoe.MessageFormat.Formatting.Formatters; +using System.Collections.Generic; + +namespace Jeffijoe.MessageFormat.Helpers; + +/// +/// Helpers for working with locale strings. +/// +internal class LocaleHelper +{ + /// + /// Partial implementation of locale inheritance + /// from the LDML spec. + /// + /// Given an input locale in BCP 47 format, yields back various strings to use as lookups in CLDR data. + /// + /// + /// This function doesn't perform any canonicalization of input or fully implement the LDML spec. + /// It first yields the input as-is, then the base language tag, then the CLDR "root" value. + /// + /// This is because at the time of authorship, the only lookups needed by this library are for CLDR plurals, + /// which almost exclusively use languages without subtags. + /// + /// + /// Given "language-Script-REGION", yields: + /// - language-Script-REGION + /// - language + /// - root + /// + /// A BCP 47 locale tag + public static IEnumerable GetInheritanceChain(string locale) + { + // 0 or 1 characters do not form a valid language ID, so we can skip those + // Also skip x- and i- as those BCP 47 tags will never match CLDR and should + // only resolve to 'root'. + if (locale.Length >= 2 && locale[1] != '-') + { + yield return locale; + } + + // If the length is 2, we don't have any subtags for valid input + if (locale.Length >= 3 && locale[1] != '-') + { + // Find the first separator character, Substring to that, and break + for (int i = 2; i < locale.Length; i++) + { + if (locale[i] == '_' || locale[i] == '-') + { + yield return locale.Substring(0, i); + break; + } + } + } + + yield return PluralRulesMetadata.RootLocale; + } +} diff --git a/src/Jeffijoe.MessageFormat/Helpers/ObjectHelper.cs b/src/Jeffijoe.MessageFormat/Helpers/ObjectHelper.cs index 55b271c..45af327 100644 --- a/src/Jeffijoe.MessageFormat/Helpers/ObjectHelper.cs +++ b/src/Jeffijoe.MessageFormat/Helpers/ObjectHelper.cs @@ -5,69 +5,71 @@ using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; -namespace Jeffijoe.MessageFormat.Helpers +namespace Jeffijoe.MessageFormat.Helpers; + +/// +/// Object helper +/// +internal static class ObjectHelper { + #region Methods + /// - /// Object helper + /// Gets the properties from the specified object. /// - internal static class ObjectHelper + /// + /// The object. + /// + /// + /// The . + /// + [RequiresUnreferencedCode("This method uses reflection to read property information on object")] + internal static IEnumerable GetProperties(object obj) { - #region Methods - - /// - /// Gets the properties from the specified object. - /// - /// - /// The object. - /// - /// - /// The . - /// - internal static IEnumerable GetProperties(object obj) + var properties = new List(); + var type = obj.GetType(); + var typeInfo = type.GetTypeInfo(); + while (true) { - var properties = new List(); - var type = obj.GetType(); - var typeInfo = type.GetTypeInfo(); - while (typeInfo != null) + properties.AddRange(typeInfo.DeclaredProperties); + if (typeInfo.BaseType == null) { - properties.AddRange(typeInfo.DeclaredProperties); - if (typeInfo.BaseType == null) - { - break; - } - - typeInfo = typeInfo.BaseType.GetTypeInfo(); + break; } - return properties; + typeInfo = typeInfo.BaseType.GetTypeInfo(); } - /// - /// Creates a dictionary from the specified object's properties. 1 level only. - /// - /// - /// The object. - /// - /// - /// The . - /// - internal static Dictionary ToDictionary(this object obj) - { - // We want to be able to read the property, and it should not be an indexer. - var properties = GetProperties(obj).Where(x => x.CanRead && x.GetIndexParameters().Any() == false); + return properties; + } - var result = new Dictionary(); - foreach (var propertyInfo in properties) - { - result[propertyInfo.Name] = propertyInfo.GetValue(obj); - } + /// + /// Creates a dictionary from the specified object's properties. 1 level only. + /// + /// + /// The object. + /// + /// + /// The . + /// + [RequiresUnreferencedCode("This method uses reflection to convert object into dictionary")] + internal static Dictionary ToDictionary(this object obj) + { + // We want to be able to read the property, and it should not be an indexer. + var properties = GetProperties(obj).Where(x => x.CanRead && x.GetIndexParameters().Any() == false); - return result; + var result = new Dictionary(); + foreach (var propertyInfo in properties) + { + result[propertyInfo.Name] = propertyInfo.GetValue(obj); } - #endregion + return result; } -} \ No newline at end of file + + #endregion +} diff --git a/src/Jeffijoe.MessageFormat/Helpers/StringBuilderHelper.cs b/src/Jeffijoe.MessageFormat/Helpers/StringBuilderHelper.cs index b83356f..e920140 100644 --- a/src/Jeffijoe.MessageFormat/Helpers/StringBuilderHelper.cs +++ b/src/Jeffijoe.MessageFormat/Helpers/StringBuilderHelper.cs @@ -3,31 +3,44 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. +#if NET5_0_OR_GREATER +using System; +#endif + using System.Text; -namespace Jeffijoe.MessageFormat.Helpers +namespace Jeffijoe.MessageFormat.Helpers; + +/// +/// String Builder helper +/// +internal static class StringBuilderHelper { + #region Methods + /// - /// String Builder helper + /// Determines whether the specified source contains any of the specified characters. /// - internal static class StringBuilderHelper + /// + /// The source. + /// + /// + /// The chars. + /// + /// + /// The . + /// + private static bool Contains(this StringBuilder src, params char[] chars) { - #region Methods - - /// - /// Determines whether the specified source contains any of the specified characters. - /// - /// - /// The source. - /// - /// - /// The chars. - /// - /// - /// The . - /// - internal static bool Contains(this StringBuilder src, params char[] chars) +#if NET5_0_OR_GREATER + foreach (var chunk in src.GetChunks()) { + if (chunk.Span.IndexOfAny(chars) != -1) + { + return true; + } + } +#else for (int i = 0; i < src.Length; i++) { foreach (var c in chars) @@ -38,73 +51,106 @@ internal static bool Contains(this StringBuilder src, params char[] chars) } } } +#endif - return false; - } - - /// - /// Determines whether the specified source contains whitespace. - /// - /// - /// The source. - /// - /// - /// The . - /// - internal static bool ContainsWhitespace(this StringBuilder src) + return false; + } + + /// + /// Determines whether the specified source contains the specified character. + /// + /// + /// The source. + /// + /// + /// The character. + /// + /// + /// The . + /// + internal static bool Contains(this StringBuilder src, char character) + { +#if NET5_0_OR_GREATER + foreach (var chunk in src.GetChunks()) { - return src.Contains(' ', '\r', '\n', '\t'); + if (chunk.Span.IndexOf(character) != -1) + return true; } - - /// - /// Trims the whitespace. - /// - /// - /// The source. - /// - /// - /// The . - /// - internal static StringBuilder TrimWhitespace(this StringBuilder src) - { - var length = 0; - +#else for (int i = 0; i < src.Length; i++) { - var c = src[i]; - if (char.IsWhiteSpace(c) == false) + if (src[i] == character) { - length = i; - break; + return true; } } +#endif - if (length != 0) - { - src = src.Remove(0, length); - } + return false; + } - var startIndex = 0; - for (int i = src.Length - 1; i >= 0; i--) + /// + /// Determines whether the specified source contains whitespace. + /// + /// + /// The source. + /// + /// + /// The . + /// + internal static bool ContainsWhitespace(this StringBuilder src) + { + return src.Contains(' ', '\r', '\n', '\t'); + } + + /// + /// Trims the whitespace. + /// + /// + /// The source. + /// + /// + /// The . + /// + internal static StringBuilder TrimWhitespace(this StringBuilder src) + { + var length = 0; + + for (int i = 0; i < src.Length; i++) + { + var c = src[i]; + if (char.IsWhiteSpace(c) == false) { - var c = src[i]; - if (char.IsWhiteSpace(c) == false) - { - startIndex = i + 1; - break; - } + length = i; + break; } + } + + if (length != 0) + { + src = src.Remove(0, length); + } - if (startIndex == src.Length) + var startIndex = 0; + for (int i = src.Length - 1; i >= 0; i--) + { + var c = src[i]; + if (char.IsWhiteSpace(c) == false) { - return src; + startIndex = i + 1; + break; } + } - length = src.Length - startIndex; - src.Remove(startIndex, length); + if (startIndex == src.Length) + { return src; } - #endregion + length = src.Length - startIndex; + src.Remove(startIndex, length); + return src; } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/IMessageFormatter.cs b/src/Jeffijoe.MessageFormat/IMessageFormatter.cs index 694134e..2029b4a 100644 --- a/src/Jeffijoe.MessageFormat/IMessageFormatter.cs +++ b/src/Jeffijoe.MessageFormat/IMessageFormatter.cs @@ -4,44 +4,42 @@ // Copyright (C) Jeff Hansen 2014. All rights reserved. using System.Collections.Generic; +using System.Globalization; -namespace Jeffijoe.MessageFormat +namespace Jeffijoe.MessageFormat; + +/// +/// The magical Message Formatter. +/// +public interface IMessageFormatter { + #region Public properties + + /// + /// The custom value formatter to use for formats like `number`, `date`, `time` etc. + /// + CustomValueFormatter? CustomValueFormatter { get; } + + #endregion + + #region Public Methods and Operators + /// - /// The magical Message Formatter. + /// Formats the message with the specified arguments. It's so magical. /// - public interface IMessageFormatter - { - #region Public Methods and Operators - - /// - /// Formats the message with the specified arguments. It's so magical. - /// - /// - /// The pattern. - /// - /// - /// The arguments. - /// - /// - /// The . - /// - string FormatMessage(string pattern, IDictionary argsMap); - - /// - /// Formats the message, and uses reflection to create a dictionary of property values from the specified object. - /// - /// - /// The pattern. - /// - /// - /// The arguments. - /// - /// - /// The . - /// - string FormatMessage(string pattern, object args); - - #endregion - } + /// + /// The pattern. + /// + /// + /// The arguments. + /// + /// + /// The culture to use, or null to use . + /// + /// + /// The . + /// + string FormatMessage(string pattern, IReadOnlyDictionary argsMap, CultureInfo? culture = null); + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Jeffijoe.MessageFormat.csproj b/src/Jeffijoe.MessageFormat/Jeffijoe.MessageFormat.csproj index 8bea7b9..793e4f7 100644 --- a/src/Jeffijoe.MessageFormat/Jeffijoe.MessageFormat.csproj +++ b/src/Jeffijoe.MessageFormat/Jeffijoe.MessageFormat.csproj @@ -1,20 +1,42 @@  - netstandard1.2;netstandard1.3;netstandard1.4;netstandard1.5;netstandard1.6;netstandard2.0;net45 True MessageFormat.snk MessageFormat - False - false - Jeff Hansen - Follow official guidelines for escaping literals (saithis, #15) - messageformat,pcl,messageformatter,xamarin,ui,messages,formatting,plural,pluralization,singular,select,strings,stringformat,format - https://github.com/jeffijoe/messageformat.net + True + Jeff Hansen + messageformat,messageformatter,xamarin,ui,messages,formatting,plural,pluralization,singular,select,strings,stringformat,format + https://github.com/jeffijoe/messageformat.net + latest + enable + net8.0;netstandard2.0;netstandard2.1;net10.0 + true + true - bin\Release\netstandard1.1\Jeffijoe.MessageFormat.xml + bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).xml + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + diff --git a/src/Jeffijoe.MessageFormat/MessageFormatter.cs b/src/Jeffijoe.MessageFormat/MessageFormatter.cs index a106250..b92fdc2 100644 --- a/src/Jeffijoe.MessageFormat/MessageFormatter.cs +++ b/src/Jeffijoe.MessageFormat/MessageFormatter.cs @@ -6,260 +6,289 @@ using System; using System.Collections.Generic; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; - using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Formatting.Formatters; using Jeffijoe.MessageFormat.Helpers; using Jeffijoe.MessageFormat.Parsing; -namespace Jeffijoe.MessageFormat +namespace Jeffijoe.MessageFormat; + +/// +/// The magical Message Formatter. +/// +public class MessageFormatter : IMessageFormatter { + #region Static Fields + + /// + /// The instance of MessageFormatter, with the default locale + cache settings. + /// + private static readonly IMessageFormatter Instance = new MessageFormatter(); + + /// + /// The lock object. + /// + private static readonly object Lock = new object(); + + #endregion + + #region Fields + + /// + /// Pattern cache. If enabled, should speed up formatting the same pattern multiple times, + /// regardless of arguments. + /// + private readonly ConcurrentDictionary? cache; + /// - /// The magical Message Formatter. + /// The formatter library. /// - public class MessageFormatter : IMessageFormatter + private readonly IFormatterLibrary library; + + /// + /// The pattern parser + /// + private readonly IPatternParser patternParser; + + #endregion + + #region Constructors and Destructors + + /// + /// Initializes a new instance of the class. + /// + /// + /// The use Cache. + /// + /// + /// The default culture to use, or null to use . + /// + /// + /// The custom value formatter to use. Can be null. + /// + public MessageFormatter(bool useCache = true, + CultureInfo? culture = null, + CustomValueFormatter? customValueFormatter = null) + : this( + patternParser: new PatternParser(new LiteralParser()), + library: new FormatterLibrary(), + useCache: useCache, + culture: culture, + customValueFormatter: customValueFormatter) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The pattern parser. + /// + /// + /// The library. + /// + /// + /// if set to true uses the cache. + /// + /// + /// The default culture to use, or null to use . + /// + /// + /// The custom value formatter to use. Can be null. + /// + internal MessageFormatter( + IPatternParser patternParser, + IFormatterLibrary library, + bool useCache, + CultureInfo? culture = null, + CustomValueFormatter? customValueFormatter = null) { - #region Static Fields - - /// - /// The instance of MessageFormatter, with the default locale + cache settings. - /// - private static readonly IMessageFormatter Instance = new MessageFormatter(); - - /// - /// The lock object. - /// - private static readonly object Lock = new object(); - - #endregion - - #region Fields - - /// - /// Pattern cache. If enabled, should speed up formatting the same pattern multiple times, - /// regardless of arguments. - /// - private readonly ConcurrentDictionary cache; - - /// - /// The formatter library. - /// - private readonly IFormatterLibrary library; - - /// - /// The pattern parser - /// - private readonly IPatternParser patternParser; - - #endregion - - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The use Cache. - /// - /// - /// The locale. - /// - public MessageFormatter(bool useCache = true, string locale = "en") - : this(new PatternParser(new LiteralParser()), new FormatterLibrary(), useCache, locale) + this.patternParser = patternParser ?? throw new ArgumentNullException("patternParser"); + this.library = library ?? throw new ArgumentNullException("library"); + this.Culture = culture; + this.CustomValueFormatter = customValueFormatter; + if (useCache) { + this.cache = new ConcurrentDictionary(); } + } - /// - /// Initializes a new instance of the class. - /// - /// - /// The pattern parser. - /// - /// - /// The library. - /// - /// - /// if set to true uses the cache. - /// - /// - /// The locale to use. Formatters may need this. - /// - internal MessageFormatter( - IPatternParser patternParser, - IFormatterLibrary library, - bool useCache, - string locale = "en") - { - if (patternParser == null) - { - throw new ArgumentNullException("patternParser"); - } + #endregion - if (library == null) - { - throw new ArgumentNullException("library"); - } + #region Public Properties - this.patternParser = patternParser; - this.library = library; - this.Locale = locale; - if (useCache) - { - this.cache = new ConcurrentDictionary(); - } - } + /// + /// The default culture to use for formatting, or null to use . + /// + public CultureInfo? Culture { get; } - #endregion + /// + /// The custom value formatter to use for formats like `number`, `date`, `time` etc. + /// + public CustomValueFormatter? CustomValueFormatter { get; private set; } - #region Public Properties + /// + /// Gets the formatters library, where you can add your own formatters if you want. + /// + /// + /// The formatters. + /// + public IFormatterLibrary Formatters + { + get { return this.library; } + } - /// - /// Gets the formatters library, where you can add your own formatters if you want. - /// - /// - /// The formatters. - /// - public IFormatterLibrary Formatters + /// + /// Gets the custom cardinal pluralizers dictionary from the , if set. Key is the locale. + /// These are the pluralizers used to translate e.g., {count, plural, one {1 book} other {# books}} + /// + /// + /// The library relies on Unicode CLDR rules for locales by default, and any values in this dictionary override those behaviors + /// for the specified locales. + /// + /// + /// The pluralizers, or null if the plural formatter has not been added. + /// + public IDictionary? CardinalPluralizers + { + get { - get - { - return this.library; - } + var pluralFormatter = this.Formatters.OfType().FirstOrDefault(); + return pluralFormatter?.CardinalPluralizers; } + } - /// - /// Gets or sets the locale. - /// - /// - /// The locale. - /// - public string Locale { get; set; } - - /// - /// Gets the pluralizers dictionary from the , if set. Key is the locale. - /// - /// - /// The pluralizers, or null if the plural formatter has not been added. - /// - public IDictionary Pluralizers + /// + /// Gets the custom ordinal number pluralizers dictionary from the , if set. Key is the locale. + /// These are the pluralizers used to translate e.g., {count, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} + /// + /// + /// The library relies on Unicode CLDR rules for locales by default, and any values in this dictionary override those behaviors + /// for the specified locales. + /// + /// + /// The pluralizers, or null if the plural formatter has not been added. + /// + public IDictionary? OrdinalPluralizers + { + get { - get - { - var pluralFormatter = this.Formatters.OfType().FirstOrDefault(); - if (pluralFormatter == null) - { - return null; - } - - return pluralFormatter.Pluralizers; - } + var pluralFormatter = this.Formatters.OfType().FirstOrDefault(); + return pluralFormatter?.OrdinalPluralizers; } + } + + #endregion + + #region Public Methods and Operators - #endregion - - #region Public Methods and Operators - - /// - /// Formats the specified pattern with the specified data. - /// - /// - /// This method calls - /// on a singleton instance using a lock. - /// Do not use in a tight loop, as a lock is being used to ensure thread safety. - /// - /// - /// The pattern. - /// - /// - /// The data. - /// - /// - /// The formatted message. - /// - public static string Format(string pattern, IDictionary data) + /// + /// Formats the specified pattern with the specified data. + /// + /// + /// This method calls + /// on a singleton instance using a lock. + /// Do not use in a tight loop, as a lock is being used to ensure thread safety. + /// + /// + /// The pattern. + /// + /// + /// The data. + /// + /// + /// The culture to use, or null to use . + /// + /// + /// The formatted message. + /// + public static string Format(string pattern, IReadOnlyDictionary data, CultureInfo? culture = null) + { + lock (Lock) { - lock (Lock) - { - return Instance.FormatMessage(pattern, data); - } + return Instance.FormatMessage(pattern, data, culture); } + } - /// - /// Formats the specified pattern with the specified data. - /// - /// This method calls - /// - /// on a singleton instance using a lock. - /// Do not use in a tight loop, as a lock is being used to ensure thread safety. - /// - /// The pattern. - /// - /// - /// The data. - /// - /// - /// The formatted message. - /// - public static string Format(string pattern, object data) + /// + /// Formats the specified pattern with the specified data. + /// + /// This method calls + /// + /// on a singleton instance using a lock. + /// Do not use in a tight loop, as a lock is being used to ensure thread safety. + /// + /// The pattern. + /// + /// + /// The data. + /// + /// + /// The culture to use, or null to use . + /// + /// + /// The formatted message. + /// + [OverloadResolutionPriority(-1)] + [RequiresUnreferencedCode("This method uses the FormatMessage extension which uses reflection to convert object into dictionary")] + public static string Format(string pattern, object data, CultureInfo? culture = null) + { + lock (Lock) { - lock (Lock) - { - return Instance.FormatMessage(pattern, data); - } + return Instance.FormatMessage(pattern, data, culture); } + } - /// - /// Formats the message with the specified arguments. It's so magical. - /// - /// - /// The pattern. - /// - /// - /// The arguments. - /// - /// - /// The . - /// - public string FormatMessage(string pattern, IDictionary args) + /// + /// Formats the message with the specified arguments. It's so magical. + /// + /// + /// The pattern. + /// + /// + /// The arguments. + /// + /// + /// The culture to use, or null to use . + /// + /// + /// The . + /// + public string FormatMessage(string pattern, IReadOnlyDictionary args, CultureInfo? culture = null) + { + /* + * We are assuming the formatters are ordered correctly + * - that is, from left to right, string-wise. + */ + var activeCulture = culture ?? this.Culture ?? CultureInfo.CurrentCulture; + var sourceBuilder = StringBuilderPool.Get(); + + try { - /* - * We are asuming the formatters are ordered correctly - * - that is, from left to right, string-wise. - */ - var sourceBuilder = new StringBuilder(pattern); + sourceBuilder.Append(pattern); var requests = this.ParseRequests(pattern, sourceBuilder); - var requestsEnumerated = requests.ToArray(); - // If we got no formatters, then we only need to unescape the literals. - if (requestsEnumerated.Length == 0) + for (int i = 0; i < requests.Count; i++) { - sourceBuilder = this.UnescapeLiterals(sourceBuilder); - return sourceBuilder.ToString(); - } + var request = requests[i]; - for (int i = 0; i < requestsEnumerated.Length; i++) - { - var request = requestsEnumerated[i]; + var formatter = this.Formatters.GetFormatter(request); - object value; - if (args.TryGetValue(request.Variable, out value) == false) + if (args.TryGetValue(request.Variable, out var value) == false && formatter.VariableMustExist) { throw new VariableNotFoundException(request.Variable); } - var formatter = this.Formatters.GetFormatter(request); - if (formatter == null) - { - throw new FormatterNotFoundException(request); - } - // Double dispatch, yeah! - var result = formatter.Format(this.Locale, request, args, value, this); + var result = formatter.Format(activeCulture, request, args, value, this); // First, we remove the literal from the source. - Literal sourceLiteral = request.SourceLiteral; + var sourceLiteral = request.SourceLiteral; // +1 because we want to include the last index. var length = (sourceLiteral.EndIndex - sourceLiteral.StartIndex) + 1; @@ -272,153 +301,131 @@ public string FormatMessage(string pattern, IDictionary args) requests.ShiftIndices(i, result.Length); } - sourceBuilder = this.UnescapeLiterals(sourceBuilder); - // And we're done. - return sourceBuilder.ToString(); + return MessageFormatter.UnescapeLiterals(sourceBuilder); } - - /// - /// Formats the message, and uses reflection to create a dictionary of property values from the specified object. - /// - /// - /// The pattern. - /// - /// - /// The arguments. - /// - /// - /// The . - /// - public string FormatMessage(string pattern, object args) + finally { - return this.FormatMessage(pattern, args.ToDictionary()); + StringBuilderPool.Return(sourceBuilder); } + } - #endregion + #endregion - #region Methods + #region Methods - /// - /// Unescapes the literals from the source builder, and returns a new instance with literals unescaped. - /// - /// - /// The source builder. - /// - /// - /// The . - /// - protected internal StringBuilder UnescapeLiterals(StringBuilder sourceBuilder) + /// + /// Unescapes the literals from the source builder, and returns a new instance with literals unescaped. + /// + /// + /// The source builder. + /// + /// + /// The . + /// + internal static string UnescapeLiterals(StringBuilder sourceBuilder) + { + // If the block is empty, do nothing. + if (sourceBuilder.Length == 0) { - // If the block is empty, do nothing. - if (sourceBuilder.Length == 0) - { - return new StringBuilder(); - } + return string.Empty; + } - var dest = new StringBuilder(sourceBuilder.Length, sourceBuilder.Length); - int length = sourceBuilder.Length; - const char EscapingChar = '\''; - const char OpenBrace = '{'; - const char CloseBrace = '}'; - var braceBalance = 0; - var insideEscapeSequence = false; + const char EscapingChar = '\''; + + if (!sourceBuilder.Contains(EscapingChar)) + { + return sourceBuilder.ToString(); + } + + var length = sourceBuilder.Length; + var insideEscapeSequence = false; + + var dest = StringBuilderPool.Get(); + + try + { for (int i = 0; i < length; i++) { var c = sourceBuilder[i]; if (c == EscapingChar) { - if (braceBalance == 0) + if (i == length - 1) { - if (i == length - 1) - { - if (!insideEscapeSequence) - dest.Append(EscapingChar); - continue; - } - - var nextChar = sourceBuilder[i + 1]; - if (nextChar == EscapingChar) - { + if (!insideEscapeSequence) dest.Append(EscapingChar); - ++i; - continue; - } - - if (insideEscapeSequence) - { - insideEscapeSequence = false; - continue; - } - - if (nextChar == '{' || nextChar == '}' || nextChar == '#') - { - dest.Append(nextChar); - insideEscapeSequence = true; - ++i; - continue; - } + continue; + } + var nextChar = sourceBuilder[i + 1]; + if (nextChar == EscapingChar) + { dest.Append(EscapingChar); + ++i; continue; } - } - else if (insideEscapeSequence) - { - // fall through to append - } - else if (c == OpenBrace) - { - braceBalance++; - } - else if (c == CloseBrace) - { - braceBalance--; + + if (insideEscapeSequence) + { + insideEscapeSequence = false; + continue; + } + + if (nextChar == '{' || nextChar == '}' || nextChar == '#') + { + dest.Append(nextChar); + insideEscapeSequence = true; + ++i; + continue; + } + + dest.Append(EscapingChar); + continue; } dest.Append(c); } - return dest; + return dest.ToString(); } - - /// - /// Parses the requests, using the cache if enabled and applicable. - /// - /// - /// The pattern. - /// - /// - /// The source builder. - /// - /// - /// The . - /// - private IFormatterRequestCollection ParseRequests(string pattern, StringBuilder sourceBuilder) + finally { - // If we are not using the cache, just parse them straight away. - if (this.cache == null) - { - return this.patternParser.Parse(sourceBuilder); - } - - // If we have a cached result from this pattern, clone it and return the clone. - IFormatterRequestCollection cached; - if (this.cache.TryGetValue(pattern, out cached)) - { - return cached.Clone(); - } + StringBuilderPool.Return(dest); + } + } - var requests = this.patternParser.Parse(sourceBuilder); - if (this.cache != null) - { - this.cache.TryAdd(pattern, requests.Clone()); - } + /// + /// Parses the requests, using the cache if enabled and applicable. + /// + /// + /// The pattern. + /// + /// + /// The source builder. + /// + /// + /// The . + /// + private IFormatterRequestCollection ParseRequests(string pattern, StringBuilder sourceBuilder) + { + // If we are not using the cache, just parse them straight away. + if (this.cache == null) + { + return this.patternParser.Parse(sourceBuilder); + } - return requests; + // If we have a cached result from this pattern, clone it and return the clone. + if (this.cache.TryGetValue(pattern, out var cached)) + { + return cached.Clone(); } - #endregion + var requests = this.patternParser.Parse(sourceBuilder); + this.cache?.TryAdd(pattern, requests.Clone()); + + return requests; } -} \ No newline at end of file + + #endregion +} diff --git a/src/Jeffijoe.MessageFormat/MessageFormatterException.cs b/src/Jeffijoe.MessageFormat/MessageFormatterException.cs index 1c666ef..764f478 100644 --- a/src/Jeffijoe.MessageFormat/MessageFormatterException.cs +++ b/src/Jeffijoe.MessageFormat/MessageFormatterException.cs @@ -5,41 +5,25 @@ using System; -namespace Jeffijoe.MessageFormat +namespace Jeffijoe.MessageFormat; + +/// +/// Thrown when an issue has occured in the message formatting process. +/// +public class MessageFormatterException : Exception { + #region Constructors and Destructors + /// - /// Thrown when an issue has occured in the message formatting process. + /// Initializes a new instance of the class. /// - public class MessageFormatterException : Exception + /// + /// The message that describes the error. + /// + public MessageFormatterException(string message) + : base(message) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The message that describes the error. - /// - public MessageFormatterException(string message) - : base(message) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// - /// The error message that explains the reason for the exception. - /// - /// - /// The exception that is the cause of the current exception, or a null reference (Nothing in - /// Visual Basic) if no inner exception is specified. - /// - public MessageFormatterException(string message, Exception innerException) - : base(message, innerException) - { - } - - #endregion } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/MessageFormatterExtensions.cs b/src/Jeffijoe.MessageFormat/MessageFormatterExtensions.cs new file mode 100644 index 0000000..8ec9d7c --- /dev/null +++ b/src/Jeffijoe.MessageFormat/MessageFormatterExtensions.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using Jeffijoe.MessageFormat.Helpers; + +namespace Jeffijoe.MessageFormat; + +/// +/// Extensions for . +/// +public static class MessageFormatterExtensions +{ + /// + /// Formats the message using the specified . + /// + /// + /// The formatter. + /// + /// + /// The pattern. + /// + /// + /// The arguments. + /// + /// + /// The culture to use, or null to use . + /// + /// + /// The . + /// + public static string FormatMessage( + this IMessageFormatter formatter, + string pattern, + IDictionary args, + CultureInfo? culture = null) + { + return formatter.FormatMessage(pattern, (IReadOnlyDictionary)args, culture); + } + + /// + /// Formats the message, and uses reflection to create a dictionary of property values from the specified object. + /// + /// + /// The formatter. + /// + /// + /// The pattern. + /// + /// + /// The arguments. + /// + /// + /// The culture to use, or null to use . + /// + /// + /// The . + /// + [OverloadResolutionPriority(-1)] + [RequiresUnreferencedCode("This method uses the ToDictionary extension which uses reflection to convert object into dictionary")] + public static string FormatMessage(this IMessageFormatter formatter, string pattern, object args, CultureInfo? culture = null) + { + return formatter.FormatMessage(pattern, args.ToDictionary(), culture); + } +} diff --git a/src/Jeffijoe.MessageFormat/ObjectPool.cs b/src/Jeffijoe.MessageFormat/ObjectPool.cs new file mode 100644 index 0000000..6b26c26 --- /dev/null +++ b/src/Jeffijoe.MessageFormat/ObjectPool.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// Ported from Roslyn, see: https://github.com/dotnet/roslyn/blob/main/src/Dependencies/PooledObjects/ObjectPool%601.cs. + +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Jeffijoe.MessageFormat; + +/// +/// Generic implementation of object pooling pattern with predefined pool size limit. The main purpose +/// is that limited number of frequently used objects can be kept in the pool for further recycling. +/// +/// The type of objects to pool. +internal sealed class ObjectPool(Func factory, int size) + where T : class +{ + private readonly Element[] _items = new Element[size - 1]; + private T? _firstItem; + + public ObjectPool(Func factory) + : this(factory, Environment.ProcessorCount * 2) + { + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T Allocate() + { + var item = _firstItem; + + if (item is null || item != Interlocked.CompareExchange(ref _firstItem, null, item)) + { + item = AllocateSlow(); + } + + return item; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Free(T obj) + { + if (_firstItem is null) + { + _firstItem = obj; + } + else + { + FreeSlow(obj); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private T AllocateSlow() + { + foreach (ref var element in _items.AsSpan()) + { + var instance = element.Value; + + if (instance is null) + { + continue; + } + + if (instance == Interlocked.CompareExchange(ref element.Value, null, instance)) + { + return instance; + } + } + + + return factory(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void FreeSlow(T obj) + { + foreach (ref var element in _items.AsSpan()) + { + if (element.Value is not null) + { + continue; + } + + element.Value = obj; + break; + } + } + + private struct Element + { + internal T? Value; + } +} diff --git a/src/Jeffijoe.MessageFormat/Parsing/FormatterRequestCollection.cs b/src/Jeffijoe.MessageFormat/Parsing/FormatterRequestCollection.cs index 5b388d4..9880799 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/FormatterRequestCollection.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/FormatterRequestCollection.cs @@ -7,59 +7,58 @@ using Jeffijoe.MessageFormat.Formatting; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Formatter requests collection. +/// +public class FormatterRequestCollection : List, IFormatterRequestCollection { + #region Public Methods and Operators + /// - /// Formatter requests collection. + /// Clones this instance and all of it's items. This lets us reuse pattern parsing result, without having to remember + /// the item's initial state before being modified to match the results of the formatters. /// - public class FormatterRequestCollection : List, IFormatterRequestCollection + /// + /// The . + /// + public IFormatterRequestCollection Clone() { - #region Public Methods and Operators - - /// - /// Clones this instance and all of it's items. This lets us reuse pattern parsing result, without having to remember - /// the item's initial state before being modified to match the results of the formatters. - /// - /// - /// The . - /// - public IFormatterRequestCollection Clone() + var result = new FormatterRequestCollection(); + foreach (var request in this) { - var result = new FormatterRequestCollection(); - foreach (var request in this) - { - result.Add(request.Clone()); - } - - return result; + result.Add(request.Clone()); } - /// - /// Updates the indices of all - /// formatter requests' source literals, starting at - /// next request after the specified index in this collection. - /// - /// - /// The index to start from. - /// - /// - /// Length of the formatter result. - /// Used to compare each literal's inner text length, so we know what to set the - /// indices to on the rest of the requests. - /// - public void ShiftIndices(int indexToStartFrom, int formatterResultLength) - { - var start = this[indexToStartFrom]; + return result; + } - // "- 2" will compensate for { and }. (This works, don't ask why). - int resultLength = formatterResultLength - 2; - for (int i = indexToStartFrom + 1; i < this.Count; i++) - { - var next = this[i]; - next.SourceLiteral.ShiftIndices(resultLength, start.SourceLiteral); - } - } + /// + /// Updates the indices of all + /// formatter requests' source literals, starting at + /// next request after the specified index in this collection. + /// + /// + /// The index to start from. + /// + /// + /// Length of the formatter result. + /// Used to compare each literal's inner text length, so we know what to set the + /// indices to on the rest of the requests. + /// + public void ShiftIndices(int indexToStartFrom, int formatterResultLength) + { + var start = this[indexToStartFrom]; - #endregion + // "- 2" will compensate for { and }. (This works, don't ask why). + int resultLength = formatterResultLength - 2; + for (int i = indexToStartFrom + 1; i < this.Count; i++) + { + var next = this[i]; + next.SourceLiteral.ShiftIndices(resultLength, start.SourceLiteral); + } } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/IFormatterRequestCollection.cs b/src/Jeffijoe.MessageFormat/Parsing/IFormatterRequestCollection.cs index 067b319..a613e09 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/IFormatterRequestCollection.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/IFormatterRequestCollection.cs @@ -7,39 +7,38 @@ using Jeffijoe.MessageFormat.Formatting; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Formatter requests collection. +/// +public interface IFormatterRequestCollection : IReadOnlyList { + #region Public Methods and Operators + /// - /// Formatter requests collection. + /// Clones this instance and all of it's items. This lets us reuse pattern parsing result, without having to remember + /// the item's initial state before being modified to match the results of the formatters. /// - public interface IFormatterRequestCollection : IEnumerable - { - #region Public Methods and Operators - - /// - /// Clones this instance and all of it's items. This lets us reuse pattern parsing result, without having to remember - /// the item's initial state before being modified to match the results of the formatters. - /// - /// - /// The . - /// - IFormatterRequestCollection Clone(); + /// + /// The . + /// + IFormatterRequestCollection Clone(); - /// - /// Updates the indices of all - /// formatter requests' source literals, starting at - /// the specified index in this collection. - /// - /// - /// The index to start from. - /// - /// - /// Length of the formatter result. - /// Used to compare each literal's inner text length, so we know what to set the - /// indices to on the rest of the requests. - /// - void ShiftIndices(int indexToStartFrom, int formatterResultLength); + /// + /// Updates the indices of all + /// formatter requests' source literals, starting at + /// the specified index in this collection. + /// + /// + /// The index to start from. + /// + /// + /// Length of the formatter result. + /// Used to compare each literal's inner text length, so we know what to set the + /// indices to on the rest of the requests. + /// + void ShiftIndices(int indexToStartFrom, int formatterResultLength); - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/ILiteralParser.cs b/src/Jeffijoe.MessageFormat/Parsing/ILiteralParser.cs index 07f427c..fae5459 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/ILiteralParser.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/ILiteralParser.cs @@ -7,26 +7,25 @@ using System.Collections.Generic; using System.Text; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Brace parser contract. +/// +public interface ILiteralParser { + #region Public Methods and Operators + /// - /// Brace parser contract. + /// Finds the brace matches. /// - public interface ILiteralParser - { - #region Public Methods and Operators - - /// - /// Finds the brace matches. - /// - /// - /// The sb. - /// - /// - /// The . - /// - IEnumerable ParseLiterals(StringBuilder sb); + /// + /// The sb. + /// + /// + /// The . + /// + IEnumerable ParseLiterals(StringBuilder sb); - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/IPatternParser.cs b/src/Jeffijoe.MessageFormat/Parsing/IPatternParser.cs index 78d29db..ad42e47 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/IPatternParser.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/IPatternParser.cs @@ -5,27 +5,26 @@ using System.Text; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// The pattern parser extracts patterns from a string. +/// +public interface IPatternParser { + #region Public Methods and Operators + /// - /// The pattern parser extracts patterns from a string. + /// Parses the source, extracting formatter parameters + /// describing what formatter to use, as well as it's options. /// - public interface IPatternParser - { - #region Public Methods and Operators - - /// - /// Parses the source, extracting formatter parameters - /// describing what formatter to use, as well as it's options. - /// - /// - /// The source. - /// - /// - /// The . - /// - IFormatterRequestCollection Parse(StringBuilder source); + /// + /// The source. + /// + /// + /// The . + /// + IFormatterRequestCollection Parse(StringBuilder source); - #endregion - } + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/Literal.cs b/src/Jeffijoe.MessageFormat/Parsing/Literal.cs index 1c00115..ff1e667 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/Literal.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/Literal.cs @@ -3,130 +3,127 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -using System.Text; +namespace Jeffijoe.MessageFormat.Parsing; -namespace Jeffijoe.MessageFormat.Parsing +/// +/// Represents a position in the source text where we should look for format patterns. +/// +public class Literal { + #region Constructors and Destructors + /// - /// Represents a position in the source text where we should look for format patterns. + /// Initializes a new instance of the class. /// - public class Literal + /// + /// The start index. + /// + /// + /// The end index. + /// + /// + /// The source line number. + /// + /// + /// The source column number. + /// + /// + /// The inner text. + /// + public Literal( + int startIndex, + int endIndex, + int sourceLineNumber, + int sourceColumnNumber, + string innerText) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The start index. - /// - /// - /// The end index. - /// - /// - /// The source line number. - /// - /// - /// The source column number. - /// - /// - /// The inner text. - /// - public Literal( - int startIndex, - int endIndex, - int sourceLineNumber, - int sourceColumnNumber, - StringBuilder innerText) - { - this.StartIndex = startIndex; - this.EndIndex = endIndex; - this.SourceLineNumber = sourceLineNumber; - this.SourceColumnNumber = sourceColumnNumber; - this.InnerText = innerText; - } - - #endregion + this.StartIndex = startIndex; + this.EndIndex = endIndex; + this.SourceLineNumber = sourceLineNumber; + this.SourceColumnNumber = sourceColumnNumber; + this.InnerText = innerText; + } - #region Public Properties + #endregion - /// - /// Gets the end index in the source string. - /// - /// - /// The end index. - /// - public int EndIndex { get; private set; } + #region Public Properties - /// - /// Gets the inner text (the content between the braces). - /// - /// - /// The inner text. - /// - public StringBuilder InnerText { get; private set; } + /// + /// Gets the end index in the source string. + /// + /// + /// The end index. + /// + public int EndIndex { get; private set; } - /// - /// Gets the source column number. - /// - /// - /// The source column number. - /// - public int SourceColumnNumber { get; private set; } + /// + /// Gets the inner text (the content between the braces). + /// + /// + /// The inner text. + /// + public string InnerText { get; private set; } - /// - /// Gets the source line number in the original input string. - /// - /// - /// The source line number. - /// - public int SourceLineNumber { get; private set; } + /// + /// Gets the source column number. + /// + /// + /// The source column number. + /// + public int SourceColumnNumber { get; private set; } - /// - /// Gets the start index in the source string. - /// - /// - /// The start index. - /// - public int StartIndex { get; private set; } + /// + /// Gets the source line number in the original input string. + /// + /// + /// The source line number. + /// + public int SourceLineNumber { get; private set; } - #endregion + /// + /// Gets the start index in the source string. + /// + /// + /// The start index. + /// + public int StartIndex { get; private set; } - #region Public Methods and Operators + #endregion - /// - /// Clones this instance. - /// - /// - /// The . - /// - public Literal Clone() - { - // Assuming that InnerText will never be tampered with. - return new Literal( - this.StartIndex, - this.EndIndex, - this.SourceLineNumber, - this.SourceColumnNumber, - this.InnerText); - } + #region Public Methods and Operators - /// - /// Updates the start and end index. - /// - /// - /// Length of the result. - /// - /// - /// The literal that was just formatted. - /// - public void ShiftIndices(int resultLength, Literal literal) - { - int offset = (literal.EndIndex - literal.StartIndex) - 1; - this.StartIndex = (this.StartIndex - offset) + resultLength; - this.EndIndex = (this.EndIndex - offset) + resultLength; - } + /// + /// Clones this instance. + /// + /// + /// The . + /// + public Literal Clone() + { + // Assuming that InnerText will never be tampered with. + return new Literal( + this.StartIndex, + this.EndIndex, + this.SourceLineNumber, + this.SourceColumnNumber, + this.InnerText); + } - #endregion + /// + /// Updates the start and end index. + /// + /// + /// Length of the result. + /// + /// + /// The literal that was just formatted. + /// + public void ShiftIndices(int resultLength, Literal literal) + { + int offset = (literal.EndIndex - literal.StartIndex) - 1; + this.StartIndex = (this.StartIndex - offset) + resultLength; + this.EndIndex = (this.EndIndex - offset) + resultLength; } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/LiteralParser.cs b/src/Jeffijoe.MessageFormat/Parsing/LiteralParser.cs index 7486b28..9a9e411 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/LiteralParser.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/LiteralParser.cs @@ -7,62 +7,68 @@ using System.Collections.Generic; using System.Text; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Parser for extracting brace matches from a string builder. +/// +public class LiteralParser : ILiteralParser { + #region Public Methods and Operators + /// - /// Parser for extracting brace matches from a string builder. + /// Finds the brace matches. /// - public class LiteralParser : ILiteralParser + /// + /// The sb. + /// + /// + /// The . + /// + public IEnumerable ParseLiterals(StringBuilder sb) { - #region Public Methods and Operators - - /// - /// Finds the brace matches. - /// - /// - /// The sb. - /// - /// - /// The . - /// - public IEnumerable ParseLiterals(StringBuilder sb) + const char OpenBrace = '{'; + const char CloseBrace = '}'; + const char EscapingChar = '\''; + + var result = new List(); + var openBraces = 0; + var closeBraces = 0; + var start = 0; + var braceBalance = 0; + var lineNumber = 1; + var startLineNumber = 1; + var startColumnNumber = 0; + var columnNumber = 0; + var insideEscapeSequence = false; + var currentEscapeSequenceLineNumber = 0; + var currentEscapeSequenceColumnNumber = 0; + const char Cr = '\r'; // Carriage return + const char Lf = '\n'; // Line feed + + var matchTextBuf = StringBuilderPool.Get(); + try { - const char OpenBrace = '{'; - const char CloseBrace = '}'; - const char EscapingChar = '\''; - - var result = new List(); - var openBraces = 0; - var closeBraces = 0; - var start = 0; - var braceBalance = 0; - var matchTextBuf = new StringBuilder(); - var lineNumber = 1; - var startLineNumber = 1; - var startColumnNumber = 0; - var columnNumber = 0; - var insideEscapeSequence = false; - var currentEscapeSequenceLineNumber = 0; - var currentEscapeSequenceColumnNumber = 0; - const char CR = '\r'; // Carriage return - const char LF = '\n'; // Line feed for (var i = 0; i < sb.Length; i++) { var c = sb[i]; - if (c == LF) + + if (c == Cr) + { + continue; + } + + if (c == Lf) { lineNumber++; columnNumber = 0; - continue; + } - - if (c == CR) + else { - continue; + columnNumber++; } - columnNumber++; - if (c == EscapingChar) { if (i == sb.Length - 1) @@ -75,6 +81,7 @@ public IEnumerable ParseLiterals(StringBuilder sb) { insideEscapeSequence = false; } + continue; } @@ -101,7 +108,6 @@ public IEnumerable ParseLiterals(StringBuilder sb) currentEscapeSequenceLineNumber = lineNumber; currentEscapeSequenceColumnNumber = columnNumber; ++i; - continue; } continue; @@ -155,9 +161,8 @@ public IEnumerable ParseLiterals(StringBuilder sb) continue; } - // Passing in the text buffer instead of the actual string to avoid allocating a new string. - result.Add(new Literal(start, i, startLineNumber, startColumnNumber, matchTextBuf)); - matchTextBuf = new StringBuilder(); + result.Add(new Literal(start, i, startLineNumber, startColumnNumber, matchTextBuf.ToString())); + matchTextBuf.Clear(); start = 0; } @@ -177,7 +182,11 @@ public IEnumerable ParseLiterals(StringBuilder sb) return result; } - - #endregion + finally + { + StringBuilderPool.Return(matchTextBuf); + } } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/MalformedLiteralException.cs b/src/Jeffijoe.MessageFormat/Parsing/MalformedLiteralException.cs index ce7ff3e..f90b14c 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/MalformedLiteralException.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/MalformedLiteralException.cs @@ -2,107 +2,107 @@ // - MalformedLiteralException.cs // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Thrown when the pattern parser finds an invalid character in a literal. +/// +public class MalformedLiteralException : MessageFormatterException { + #region Constructors and Destructors + /// - /// Thrown when the pattern parser finds an invalid character in a literal. + /// Initializes a new instance of the class. /// - public class MalformedLiteralException : MessageFormatterException + /// + /// The message that describes the error. + /// + /// + /// The line number. + /// + /// + /// The column number. + /// + /// + /// A snippet of the text that contained the error. Can be null. + /// + internal MalformedLiteralException( + string message, + int lineNumber = 0, + int columnNumber = 0, + string? sourceSnippet = null) + : base(BuildMessage(message, lineNumber, columnNumber, sourceSnippet)) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The message that describes the error. - /// - /// - /// The line number. - /// - /// - /// The column number. - /// - /// - /// A snippet of the text that contained the error. Can be null. - /// - internal MalformedLiteralException( - string message, - int lineNumber = 0, - int columnNumber = 0, - string sourceSnippet = null) - : base(BuildMessage(message, lineNumber, columnNumber, sourceSnippet)) - { - this.LineNumber = lineNumber; - this.ColumnNumber = columnNumber; - } + this.LineNumber = lineNumber; + this.ColumnNumber = columnNumber; + this.SourceSnippet = sourceSnippet; + } - #endregion + #endregion - #region Public Properties + #region Public Properties - /// - /// Gets the column number. - /// - /// - /// The column number. - /// - public int ColumnNumber { get; private set; } + /// + /// Gets the column number. + /// + /// + /// The column number. + /// + public int ColumnNumber { get; private set; } - /// - /// Gets the line number. - /// - /// - /// The line number. - /// - public int LineNumber { get; private set; } + /// + /// Gets the line number. + /// + /// + /// The line number. + /// + public int LineNumber { get; private set; } - /// - /// Gets the source snippet. - /// - /// - /// The source snippet. - /// - public string SourceSnippet { get; private set; } + /// + /// Gets the source snippet. + /// + /// + /// The source snippet. + /// + public string? SourceSnippet { get; private set; } - #endregion + #endregion - #region Methods + #region Methods - /// - /// Builds the message. - /// - /// - /// The message. - /// - /// - /// The line number. - /// - /// - /// The column number. - /// - /// - /// The source snippet. - /// - /// - /// The . - /// - private static string BuildMessage(string message, int lineNumber, int columnNumber, string sourceSnippet) + /// + /// Builds the message. + /// + /// + /// The message. + /// + /// + /// The line number. + /// + /// + /// The column number. + /// + /// + /// The source snippet. + /// + /// + /// The . + /// + private static string BuildMessage(string message, int lineNumber, int columnNumber, string? sourceSnippet) + { + var str = message; + if (lineNumber != 0 && columnNumber != 0) { - var str = message; - if (lineNumber != 0 && columnNumber != 0) - { - str = string.Format("{0}\r\nLine {1}, column {2}", message, lineNumber, columnNumber); - } - - if (string.IsNullOrWhiteSpace(sourceSnippet)) - { - return str; - } + str = string.Format("{0}\r\nLine {1}, column {2}", message, lineNumber, columnNumber); + } - return string.Format("Parser error: {0}\r\nOffending snippet: \"{1}\"", str, sourceSnippet); + if (string.IsNullOrWhiteSpace(sourceSnippet)) + { + return str; } - #endregion + return string.Format("Parser error: {0}\r\nOffending snippet: \"{1}\"", str, sourceSnippet); } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/PatternParser.cs b/src/Jeffijoe.MessageFormat/Parsing/PatternParser.cs index f99bbdc..7ca770c 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/PatternParser.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/PatternParser.cs @@ -3,130 +3,142 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. +#if NET5_0_OR_GREATER using System; +#endif using System.Linq; using System.Text; - using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Helpers; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Parser for extracting formatter patterns. +/// +public class PatternParser : IPatternParser { + #region Fields + /// - /// Parser for extracting formatter patterns. + /// The _literal parser. /// - public class PatternParser : IPatternParser - { - #region Fields - - /// - /// The _literal parser. - /// - private readonly ILiteralParser literalParser; - - #endregion + private readonly ILiteralParser literalParser; - #region Constructors and Destructors + #endregion - /// - /// Initializes a new instance of the class. - /// - /// - /// The literal parser. - /// - public PatternParser(ILiteralParser literalParser) - { - if (literalParser == null) - { - throw new ArgumentNullException("literalParser"); - } + #region Constructors and Destructors - this.literalParser = literalParser; - } + /// + /// Initializes a new instance of the class. + /// + public PatternParser() : this(new LiteralParser()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The literal parser. + /// + public PatternParser(ILiteralParser literalParser) + { + this.literalParser = literalParser; + } - #endregion + #endregion - #region Public Methods and Operators + #region Public Methods and Operators - /// - /// Parses the specified source. - /// - /// - /// The source. - /// - /// - /// The . - /// - public IFormatterRequestCollection Parse(StringBuilder source) + /// + /// Parses the specified source. + /// + /// + /// The source. + /// + /// + /// The . + /// + public IFormatterRequestCollection Parse(StringBuilder source) + { + var literals = this.literalParser.ParseLiterals(source).ToArray(); + if (literals.Length == 0) { - var literals = this.literalParser.ParseLiterals(source).ToArray(); - if (literals.Length == 0) - { - return new FormatterRequestCollection(); - } + return new FormatterRequestCollection(); + } - var result = new FormatterRequestCollection(); - foreach (var literal in literals) - { - // The first token to follow an opening brace will be the variable name. - int lastIndex; - string variableName = ReadLiteralSection(literal, 0, false, out lastIndex); + var result = new FormatterRequestCollection(); + foreach (var literal in literals) + { + // The first token to follow an opening brace will be the variable name. + var variableName = ReadLiteralSection(literal, 0, false, out var lastIndex)!; - // The next (if any), is the formatter to use. Null is allowed. - string formatterKey = null; + // The next (if any), is the formatter to use. Null is allowed. + string? formatterKey = null; - // The rest of the string is what we pass into the formatter. Can be null. - string formatterArgs = null; - if (variableName.Length != literal.InnerText.Length) + // The rest of the string is what we pass into the formatter. Can be null. + string? formatterArgs = null; + if (variableName.Length != literal.InnerText.Length) + { + formatterKey = ReadLiteralSection(literal, lastIndex + 1, true, out lastIndex); + if (formatterKey != null) { - formatterKey = ReadLiteralSection(literal, variableName.Length + 1, true, out lastIndex); - if (formatterKey != null) - { +#if NET5_0_OR_GREATER formatterArgs = - literal.InnerText.ToString(lastIndex + 1, literal.InnerText.Length - lastIndex - 1).Trim(); - } + literal.InnerText.AsSpan(lastIndex + 1, literal.InnerText.Length - lastIndex - 1).Trim() + .ToString(); +#else + formatterArgs = + literal.InnerText.Substring(lastIndex + 1, literal.InnerText.Length - lastIndex - 1).Trim(); +#endif } - - result.Add(new FormatterRequest(literal, variableName, formatterKey, formatterArgs)); } - return result; + result.Add(new FormatterRequest(literal, variableName, formatterKey, formatterArgs)); } - #endregion - - #region Methods - - /// - /// Gets the key from the literal. - /// - /// - /// The literal. - /// - /// - /// The offset. - /// - /// - /// if set to true, allows an empty result, in which case the return value is - /// null - /// - /// - /// The last index. - /// - /// - /// The . - /// - /// - /// Parsing the variable key yielded an empty string. - /// - internal static string ReadLiteralSection(Literal literal, int offset, bool allowEmptyResult, out int lastIndex) + return result; + } + + #endregion + + #region Methods + + /// + /// Gets the key from the literal. + /// + /// + /// The literal. + /// + /// + /// The offset. + /// + /// + /// if set to true, allows an empty result, in which case the return value is + /// null + /// + /// + /// The last index. + /// + /// + /// The . + /// + /// + /// Parsing the variable key yielded an empty string. + /// + internal static string? ReadLiteralSection(Literal literal, int offset, bool allowEmptyResult, + out int lastIndex) + { + const char Comma = ','; + + var innerText = literal.InnerText; + var column = literal.SourceColumnNumber; + var foundWhitespace = false; + lastIndex = 0; + var sb = StringBuilderPool.Get(); + try { - const char Comma = ','; - var sb = new StringBuilder(); - var innerText = literal.InnerText; - var column = literal.SourceColumnNumber; - var foundWhitespace = false; - lastIndex = 0; for (var i = offset; i < innerText.Length; i++) { var c = innerText[i]; @@ -138,15 +150,19 @@ internal static string ReadLiteralSection(Literal literal, int offset, bool allo } // Disregard whitespace. - var whitespace = c == ' ' || c == '\r' || c == '\n' || c == '\t'; + var whitespace = char.IsWhiteSpace(c); if (!whitespace) { if (c.IsAlphaNumeric() == false) { - var msg = string.Format("Invalid literal character '{0}'.", c); + var msg = $"Invalid literal character '{c}'."; // Line number can't have changed. - throw new MalformedLiteralException(msg, literal.SourceLineNumber, column, innerText.ToString()); + throw new MalformedLiteralException( + msg, + literal.SourceLineNumber, + column, + innerText); } } else @@ -168,15 +184,23 @@ internal static string ReadLiteralSection(Literal literal, int offset, bool allo StringBuilder trimmed = sb.TrimWhitespace(); if (trimmed.Length == 0) { - return null; + if (allowEmptyResult) + { + return null; + } + + throw new MalformedLiteralException( + "Parsing the literal yielded a string that was pure whitespace.", + literal.SourceLineNumber, + column); } if (trimmed.ContainsWhitespace()) { throw new MalformedLiteralException( - "Parsed literal must not contain whitespace.", - 0, - 0, + "Parsed literal must not contain whitespace.", + 0, + 0, trimmed.ToString()); } @@ -189,11 +213,15 @@ internal static string ReadLiteralSection(Literal literal, int offset, bool allo } throw new MalformedLiteralException( - "Parsing the literal yielded an empty string.", - literal.SourceLineNumber, + "Parsing the literal yielded an empty string.", + literal.SourceLineNumber, column); } - - #endregion + finally + { + StringBuilderPool.Return(sb); + } } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Parsing/UnbalancedBracesException.cs b/src/Jeffijoe.MessageFormat/Parsing/UnbalancedBracesException.cs index 3b84085..6a0f7a5 100644 --- a/src/Jeffijoe.MessageFormat/Parsing/UnbalancedBracesException.cs +++ b/src/Jeffijoe.MessageFormat/Parsing/UnbalancedBracesException.cs @@ -5,87 +5,81 @@ using System; -namespace Jeffijoe.MessageFormat.Parsing +namespace Jeffijoe.MessageFormat.Parsing; + +/// +/// Thrown when the amount of open and close braces does not match. +/// +public class UnbalancedBracesException : ArgumentException { + #region Constructors and Destructors + /// - /// Thrown when the amount of open and close braces does not match. + /// Initializes a new instance of the class. /// - public class UnbalancedBracesException : ArgumentException + /// + /// The brace counter. + /// + /// + /// The close brace count. + /// + internal UnbalancedBracesException(int openBraceCount, int closeBraceCount) + : base(BuildMessage(openBraceCount, closeBraceCount)) { - #region Constructors and Destructors - - /// - /// Initializes a new instance of the class. - /// - /// - /// The brace counter. - /// - /// - /// The close brace count. - /// - internal UnbalancedBracesException(int openBraceCount, int closeBraceCount) - : base(BuildMessage(openBraceCount, closeBraceCount)) - { - this.OpenBraceCount = openBraceCount; - this.CloseBraceCount = closeBraceCount; - } + this.OpenBraceCount = openBraceCount; + this.CloseBraceCount = closeBraceCount; + } - #endregion + #endregion - #region Public Properties + #region Public Properties - /// - /// Gets the close brace count. - /// - /// - /// The close brace count. - /// - public int CloseBraceCount { get; private set; } + /// + /// Gets the close brace count. + /// + /// + /// The close brace count. + /// + public int CloseBraceCount { get; private set; } - /// - /// Gets the brace count. - /// - /// - /// The brace count. - /// - public int OpenBraceCount { get; private set; } + /// + /// Gets the brace count. + /// + /// + /// The brace count. + /// + public int OpenBraceCount { get; private set; } - #endregion + #endregion - #region Methods + #region Methods - /// - /// Builds the message. - /// - /// - /// The bracket counter. - /// - /// - /// The close brace count. - /// - /// - /// The . - /// - /// - /// Bracket counter was 0, which would indicate success. - /// - private static string BuildMessage(int openBraceCount, int closeBraceCount) + /// + /// Builds the message. + /// + /// + /// The bracket counter. + /// + /// + /// The close brace count. + /// + /// + /// The . + /// + /// + /// Bracket counter was 0, which would indicate success. + /// + private static string BuildMessage(int openBraceCount, int closeBraceCount) + { + if (openBraceCount > closeBraceCount) { - if (openBraceCount == closeBraceCount) - { - throw new ArgumentException("Bracket counter was 0, which would indicate success."); - } - - if (openBraceCount > closeBraceCount) - { - return "There are " + (openBraceCount - closeBraceCount) - + " more opening braces than there are closing braces."; - } - - return "There are " + (closeBraceCount - openBraceCount) - + " more closing braces than there are opening braces."; + return "There are " + (openBraceCount - closeBraceCount) + + " more opening braces than there are closing braces."; } - #endregion + return "There are " + (closeBraceCount - openBraceCount) + + " more closing braces than there are opening braces."; } + + #endregion } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Properties/AssemblyInfo.cs b/src/Jeffijoe.MessageFormat/Properties/AssemblyInfo.cs index d3b706a..8df6583 100644 --- a/src/Jeffijoe.MessageFormat/Properties/AssemblyInfo.cs +++ b/src/Jeffijoe.MessageFormat/Properties/AssemblyInfo.cs @@ -3,31 +3,7 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. -using System.Reflection; -using System.Resources; using System.Runtime.CompilerServices; -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Jeffijoe.MessageFormat")] -[assembly: AssemblyDescription("ICU Message Format for .NET.\r\n\r\nCheck the README on Github for more information.")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("MessageFormatter for .NET")] -[assembly: AssemblyCopyright("Copyright © Jeff Hansen 2018")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] [assembly: InternalsVisibleTo("Jeffijoe.MessageFormat.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001004f0dc10ad8873751f986416a7d3134e473ad3454a7138e26cf9760eff341709fc50e69d985b4e0ea512b5628079043a31ce1e402f4eaebffb5772eed9ef3a7a9e39f122633f19529a1e9988005289ed09a1e294abe13152afebc59835c2fef2879d8641b564daa7f511298ec2f8a3e9b322819e05cbaea0bfc73fe100615bfc7")] - -// Version information for an assembly consists of the following four values: -// Major Version -// Minor Version -// Build Number -// Revision -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("3.0.1")] -[assembly: AssemblyFileVersion("3.0.1")] +[assembly: InternalsVisibleTo("Jeffijoe.MessageFormat.Benchmarks, PublicKey=00240000048000009400000006020000002400005253413100040000010001004f0dc10ad8873751f986416a7d3134e473ad3454a7138e26cf9760eff341709fc50e69d985b4e0ea512b5628079043a31ce1e402f4eaebffb5772eed9ef3a7a9e39f122633f19529a1e9988005289ed09a1e294abe13152afebc59835c2fef2879d8641b564daa7f511298ec2f8a3e9b322819e05cbaea0bfc73fe100615bfc7")] diff --git a/src/Jeffijoe.MessageFormat/StringBuilderPool.cs b/src/Jeffijoe.MessageFormat/StringBuilderPool.cs new file mode 100644 index 0000000..7d0a1cb --- /dev/null +++ b/src/Jeffijoe.MessageFormat/StringBuilderPool.cs @@ -0,0 +1,28 @@ +using System.Text; + +namespace Jeffijoe.MessageFormat; + +internal static class StringBuilderPool +{ + private const int MaxBuilderCapacity = 4096; + + private static readonly ObjectPool SbPool = new(static () => new StringBuilder()); + + public static StringBuilder Get() + { + return SbPool.Allocate(); + } + + public static void Return(StringBuilder sb) + { + // If the builder grew too large, just let it go + // rather than returning it so it can get garbage-collected. + if (sb.Capacity > MaxBuilderCapacity) + { + return; + } + + sb.Clear(); + SbPool.Free(sb); + } +} diff --git a/src/MessageFormat.nuspec b/src/MessageFormat.nuspec deleted file mode 100644 index e35165f..0000000 --- a/src/MessageFormat.nuspec +++ /dev/null @@ -1,50 +0,0 @@ - - - - MessageFormat - 3.0.1 - MessageFormatter for .NET - Jeff Hansen - Jeff Hansen - false - https://github.com/jeffijoe/messageformat.net - PHP has it. Java has it. Even JavaScript has it. It's time .NET joined in with support for the ICU Message Format. - -Check the README on Github for more information. - An implementation of the ICU MessageFormatter for .NET - write contextual UI messages with proper pluralization, and more. Works with Xamarin! - Fix escaping (#16, @saithis) - Copyright © Jeff Hansen 2017 to present. All rights reserved. - en-US - messageformat,pcl,messageformatter,xamarin,ui,messages,formatting,plural,pluralization,singular,select,strings,stringformat,format - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/MessageFormat.sln b/src/MessageFormat.sln index e2bd321..d682f2e 100644 --- a/src/MessageFormat.sln +++ b/src/MessageFormat.sln @@ -1,28 +1,79 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26403.7 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31112.23 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jeffijoe.MessageFormat", "Jeffijoe.MessageFormat\Jeffijoe.MessageFormat.csproj", "{7D16B114-A482-4FC4-A055-8E96573BE2A3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jeffijoe.MessageFormat", "Jeffijoe.MessageFormat\Jeffijoe.MessageFormat.csproj", "{7D16B114-A482-4FC4-A055-8E96573BE2A3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jeffijoe.MessageFormat.Tests", "Jeffijoe.MessageFormat.Tests\Jeffijoe.MessageFormat.Tests.csproj", "{F1AC744E-9031-468E-A397-6F44AA19EBA1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jeffijoe.MessageFormat.Tests", "Jeffijoe.MessageFormat.Tests\Jeffijoe.MessageFormat.Tests.csproj", "{F1AC744E-9031-468E-A397-6F44AA19EBA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jeffijoe.MessageFormat.MetadataGenerator", "Jeffijoe.MessageFormat.MetadataGenerator\Jeffijoe.MessageFormat.MetadataGenerator.csproj", "{5431C848-23D1-4752-A9B0-5159E5B2F92E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jeffijoe.MessageFormat.Benchmarks", "Jeffijoe.MessageFormat.Benchmarks\Jeffijoe.MessageFormat.Benchmarks.csproj", "{D63A7E6E-D302-44E2-A355-F72DD005AB57}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Debug|x64.Build.0 = Debug|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Debug|x86.Build.0 = Debug|Any CPU {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Release|Any CPU.Build.0 = Release|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Release|x64.ActiveCfg = Release|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Release|x64.Build.0 = Release|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Release|x86.ActiveCfg = Release|Any CPU + {7D16B114-A482-4FC4-A055-8E96573BE2A3}.Release|x86.Build.0 = Release|Any CPU {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Debug|x64.ActiveCfg = Debug|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Debug|x64.Build.0 = Debug|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Debug|x86.ActiveCfg = Debug|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Debug|x86.Build.0 = Debug|Any CPU {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Release|Any CPU.ActiveCfg = Release|Any CPU {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Release|Any CPU.Build.0 = Release|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Release|x64.ActiveCfg = Release|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Release|x64.Build.0 = Release|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Release|x86.ActiveCfg = Release|Any CPU + {F1AC744E-9031-468E-A397-6F44AA19EBA1}.Release|x86.Build.0 = Release|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Debug|x64.ActiveCfg = Debug|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Debug|x64.Build.0 = Debug|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Debug|x86.ActiveCfg = Debug|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Debug|x86.Build.0 = Debug|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Release|Any CPU.Build.0 = Release|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Release|x64.ActiveCfg = Release|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Release|x64.Build.0 = Release|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Release|x86.ActiveCfg = Release|Any CPU + {5431C848-23D1-4752-A9B0-5159E5B2F92E}.Release|x86.Build.0 = Release|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Debug|x64.ActiveCfg = Debug|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Debug|x64.Build.0 = Debug|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Debug|x86.ActiveCfg = Debug|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Debug|x86.Build.0 = Debug|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Release|Any CPU.Build.0 = Release|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Release|x64.ActiveCfg = Release|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Release|x64.Build.0 = Release|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Release|x86.ActiveCfg = Release|Any CPU + {D63A7E6E-D302-44E2-A355-F72DD005AB57}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A7BC8D54-673A-497B-A89E-B5D1BC8CD506} + EndGlobalSection EndGlobal diff --git a/src/MessageFormat.sln.DotSettings b/src/MessageFormat.sln.DotSettings new file mode 100644 index 0000000..0fee803 --- /dev/null +++ b/src/MessageFormat.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/Settings.StyleCop b/src/Settings.StyleCop deleted file mode 100644 index 3b35ae0..0000000 --- a/src/Settings.StyleCop +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - - - - - - False - - - - - False - - - - - - - - - - False - - - - - - - - - - False - - - - - False - - - - - False - - - - - False - - - - - - - - - - False - - - - - False - - - - - False - - - - - - - \ No newline at end of file