diff --git a/CHANGES.md b/CHANGES.md index 556639f7..3f449798 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -111,7 +111,74 @@ In v2, Alignment of output values was limited to the `DefaultFormatter`. It's ab * Introduced `FormatterSettings.AlignmentFillCharacter`, to customize the the fill character. Default is space (0x20), like with `string.Format`. * Modified `ListFormatter` so that items can be aligned (but the spacers stay untouched). -### 8. Added `StringSource` as another `ISource` ([#178](https://github.com/axuno/SmartFormat/pull/178), [#216](https://github.com/axuno/SmartFormat/pull/216)) +### 9. Added `PersistentVariableSource` and `GlobalVariableSource` ([#233](https://github.com/axuno/SmartFormat/pull/233)) + +Both provide global variables that are stored in `VariablesGroup` containers to the `SmartFormatter`. These variables are not passed in as arguments when formatting a string. Instead, they are taken from these registered `ISource`s. + +`VariablesGroup`s may contain `Variable`s or other `VariablesGroup`s. The depth of such a tree is unlimited. + +a) `GlobalVariableSource` variables are static and are shared with all `SmartFormatter` instances. +b) `PersistentVariableSource` variables are stored per instance. + +`PersistentVariableSource` and `GlobalVariableSource` must be configured and registered as `ISource` extensions as shown in the example below. + +#### Example + +```Text +PersistentVariablesSource +or GlobalVariablesSource (Containers for Variable / VariablesGroup children) +| ++---- VariablesGroup "group" +| | +| +---- StringVariable "groupString", Value: "groupStringValue" +| | +| +---- Variable "groupDateTime", Value: 2024-12-31 +| ++---- StringVariable "topInteger", Value: 12345 +| ++---- StringVariable "topString", Value: "topStringValue" +``` +Here, we use the `PersistentVariablesSource`: +```CSharp +// The top container +// It gets its name later, when being added to the PersistentVariablesSource +var varGroup = new VariablesGroup(); + +// Add a (nested) VariablesGroup named 'group' to the top container +varGroup.Add("group", new VariablesGroup +{ + // Add variables to the group + { "groupString", new StringVariable("groupStringValue") }, + { "groupDateTime", new Variable(new DateTime(2024, 12, 31)) } +}); +// Add more variables to the container +varGroup.Add("topInteger", new IntVariable(12345)); +varGroup.Add("topString", new StringVariable("topStringValue")); + +// The formatter for persistent variables requires only 2 extensions +var smart = new SmartFormatter(); +smart.FormatterExtensions.Add(new DefaultFormatter()); +var pvs = new PersistentVariablesSource +{ + // Here, the top container gets its name + { "global", varGroup } +}; +// Best to put it to the top of source extensions +smart.AddExtensions(0, pvs); + +// Note: We don't need args to the formatter for PersistentVariablesSource variables +_ = smart.Format(CultureInfo.InvariantCulture, + "{global.group.groupString} {global.group.groupDateTime:'groupDateTime='yyyy-MM-dd}"); +// result: "groupStringValue groupDateTime=2024-12-31" + +_ = smart.Format("{global.topInteger}"); +// result: "12345" + +_ = smart.Format("{global.topString}"); +// result: "topStringValue" +``` + +### 9. Added `StringSource` as another `ISource` ([#178](https://github.com/axuno/SmartFormat/pull/178), [#216](https://github.com/axuno/SmartFormat/pull/216)) The `StringSource` takes over a part of the functionality, which has been implemented in `ReflectionSource` in v2. Compared to reflection **with** caching, speed is 20% better at 25% less memory allocation. `StringSource` brings the following built-in methods (as selector names): @@ -135,7 +202,7 @@ Smart.Format("{0.ToLower.TrimStart.TrimEnd.ToBase64}", " ABCDE "); // result: "YWJjZGU=" ``` -### 9. Introduced Nullable Notation ([#176](https://github.com/axuno/SmartFormat/pull/176)) +### 10. Introduced Nullable Notation ([#176](https://github.com/axuno/SmartFormat/pull/176)) C# like `nullable` notation allows to display `Nullable` types. @@ -149,7 +216,7 @@ All `Format()` methods accept nullable args (**[#196](https://github.com/axuno/S Opposed to `string.Format` null(able) arguments are allowed. -### 10. Added `NullFormatter` ([#176](https://github.com/axuno/SmartFormat/pull/176), [#199](https://github.com/axuno/SmartFormat/pull/199)) +### 11. Added `NullFormatter` ([#176](https://github.com/axuno/SmartFormat/pull/176), [#199](https://github.com/axuno/SmartFormat/pull/199)) In the context of Nullable Notation, the `NullFormatter` has been added. It outputs a custom string literal, if the variable is `null`, else another literal (default is `string.Empty`) or `Placeholder`. @@ -161,7 +228,7 @@ Smart.Format("{TheValue:isnull:The value is null|The value is {}}", new {TheValu // Result: "The value is 1234" ``` -### 11. Added `LocalizationFormatter` ([#176](https://github.com/axuno/SmartFormat/pull/207)) +### 12. Added `LocalizationFormatter` ([#176](https://github.com/axuno/SmartFormat/pull/207)) #### Features * Added `LocalizationFormatter` to localize literals and placeholders @@ -198,14 +265,14 @@ _ = Smart.Format("{0:plural:{:L(fr):{} item}|{:L(fr):{} items}}", 200; // result for French: 200 éléments ``` -### 12. Improved custom `ISource` and `IFormatter` implementations ([#180](https://github.com/axuno/SmartFormat/pull/180)) +### 13. Improved custom `ISource` and `IFormatter` implementations ([#180](https://github.com/axuno/SmartFormat/pull/180)) Any custom exensions can implement `IInitializer`. Then, the `SmartFormatter` will call `Initialize(SmartFormatter smartFormatter)` of the extension, before adding it to the extension list. -### 13. `IFormatter`s have one single, unique name ([#185](https://github.com/axuno/SmartFormat/pull/185)) +### 14. `IFormatter`s have one single, unique name ([#185](https://github.com/axuno/SmartFormat/pull/185)) In v2, `IFormatter`s could have an unlimited number of names. To improve performance, in v3, this is limited to one single, unique name. -### 14. JSON support ([#177](https://github.com/axuno/SmartFormat/pull/177), [#201](https://github.com/axuno/SmartFormat/pull/201)) +### 15. JSON support ([#177](https://github.com/axuno/SmartFormat/pull/177), [#201](https://github.com/axuno/SmartFormat/pull/201)) Separation of `JsonSource` into 2 `ISource` extensions: * `NewtonSoftJsonSource` @@ -213,14 +280,14 @@ Separation of `JsonSource` into 2 `ISource` extensions: Fix: `NewtonSoftJsonSource` handles `null` values correctly ([#201](https://github.com/axuno/SmartFormat/pull/201)) -### 15. `SmartFormatter` takes `IList` parameters +### 16. `SmartFormatter` takes `IList` parameters Added support for `IList` parameters to the `SmartFormatter` (thanks to [@karljj1](https://github.com/karljj1)) ([#154](https://github.com/axuno/SmartFormat/pull/154)) -### 16. `SmartObjects` have been removed +### 18. `SmartObjects` have been removed * Removed obsolete `SmartObjects` (which have been replaced by `ValueTuple`) ([`092b7b1`](https://github.com/axuno/SmartFormat/commit/092b7b1b5873301bdfeb2b62f221f936efc81430)) -### 17. Improved parsing of HTML input ([#203](https://github.com/axuno/SmartFormat/pull/203)) +### 18. Improved parsing of HTML input ([#203](https://github.com/axuno/SmartFormat/pull/203)) Introduced experimental `bool ParserSettings.ParseInputAsHtml`. The default is `false`. @@ -234,7 +301,7 @@ Best results can only be expected with clean HTML: balanced opening and closing SmartFormat is not a fully-fledged HTML parser. If this is required, use [AngleSharp](https://anglesharp.github.io/) or [HtmlAgilityPack](https://html-agility-pack.net/). -### 18. Refactored `PluralLocalizationFormatter` ([#209](https://github.com/axuno/SmartFormat/pull/209)) +### 19. Refactored `PluralLocalizationFormatter` ([#209](https://github.com/axuno/SmartFormat/pull/209)) * Constructor with string argument for default language is obsolete. * Property `DefaultTwoLetterISOLanguageName` is obsolete. @@ -243,7 +310,7 @@ SmartFormat is not a fully-fledged HTML parser. If this is required, use [AngleS b) Get the culture from the `IFormatProvider` argument (which may be a `CultureInfo`) to `SmartFormatter.Format(IFormatProvider, string, object?[])`
c) The `CultureInfo.CurrentUICulture`
-### 19. Refactored `TimeFormatter` ([#220](https://github.com/axuno/SmartFormat/pull/220), [#221](https://github.com/axuno/SmartFormat/pull/221)) +### 20. Refactored `TimeFormatter` ([#220](https://github.com/axuno/SmartFormat/pull/220), [#221](https://github.com/axuno/SmartFormat/pull/221)) * Constructor with string argument for default language is obsolete. * Property `DefaultTwoLetterISOLanguageName` is obsolete. @@ -295,7 +362,7 @@ SmartFormat is not a fully-fledged HTML parser. If this is required, use [AngleS // result: "25 heures 1 minute" ``` -### 20. Thread Safety ([#229](https://github.com/axuno/SmartFormat/pull/229)) +### 21. Thread Safety ([#229](https://github.com/axuno/SmartFormat/pull/229)) SmartFormat makes heavy use of caching and object pooling for expensive operations, which both require `static` containers. @@ -312,7 +379,7 @@ a) Instantiating `SmartFormatter`s from a single thread: The simplified `Smart.Format(...)` API overloads are allowed here. -### 21. How to benefit from object pooling ([#229](https://github.com/axuno/SmartFormat/pull/229)) +### 22. How to benefit from object pooling ([#229](https://github.com/axuno/SmartFormat/pull/229)) In order to return "smart" objects back to the object pool, its important to use one of the following patterns. @@ -340,7 +407,7 @@ var resultString = smart.Format(parsedFormat); parsedFormat.Dispose(); ``` -### 22. Miscellaneous +### 23. Miscellaneous * Since [#228](https://github.com/axuno/SmartFormat/pull/228) there are no more `Cysharp.Text` classes used in the `SmartFormat` namespace * Created class `ZStringBuilder` as a wrapper around `Utf16ValueStringBuilder`. * Replaced occurrences of `Utf16ValueStringBuilder` with `ZStringBuilder`. diff --git a/src/SmartFormat.Tests/Extensions/GlobalVariableSourceTests.cs b/src/SmartFormat.Tests/Extensions/GlobalVariableSourceTests.cs new file mode 100644 index 00000000..6c6f6944 --- /dev/null +++ b/src/SmartFormat.Tests/Extensions/GlobalVariableSourceTests.cs @@ -0,0 +1,75 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using SmartFormat.Core.Settings; +using SmartFormat.Extensions; +using SmartFormat.Extensions.PersistentVariables; + +namespace SmartFormat.Tests.Extensions +{ + [TestFixture] + public class GlobalVariableSourceTests + { + [Test] + public void Global_Variables_In_Different_SmartFormatters() + { + const string formatString = "{global.theVariable}"; + + var globalGrp = new VariablesGroup + { { "theVariable", new StringVariable("val-from-global-source") } }; + + GlobalVariablesSource.Instance.Add("global", globalGrp); + + var smart1 = new SmartFormatter(); + smart1.FormatterExtensions.Add(new DefaultFormatter()); + smart1.AddExtensions(0, GlobalVariablesSource.Instance); + + var smart2 = new SmartFormatter(); + smart2.FormatterExtensions.Add(new DefaultFormatter()); + smart2.AddExtensions(0, GlobalVariablesSource.Instance); + + var result1 = smart1.Format(formatString); + var result2 = smart2.Format(formatString); + + Assert.That(result1, Is.EqualTo(result2)); + } + + [Test] + public void Reset_Should_Create_A_New_Instance() + { + var globalGrp = new VariablesGroup + { { "theVariable", new StringVariable("val-from-global-source") } }; + + GlobalVariablesSource.Instance.Add("global", globalGrp); + + var ref1 = GlobalVariablesSource.Instance; + GlobalVariablesSource.Reset(); + var ref2 = GlobalVariablesSource.Instance; + + Assert.That(!ReferenceEquals(ref1, ref2), "Different references after ResetInstance()"); + Assert.That(ref2.Count, Is.EqualTo(0)); + } + + [Test] + public void Parallel_Load_By_Adding_Variables_To_Instance() + { + // Switch to thread safety - otherwise the test would throw an InvalidOperationException + const bool currentThreadSafeMode = true; + var savedIsThreadSafeMode = SmartSettings.IsThreadSafeMode; + SmartSettings.IsThreadSafeMode = currentThreadSafeMode; + + GlobalVariablesSource.Instance.Add("global", new VariablesGroup()); + + var options = new ParallelOptions { MaxDegreeOfParallelism = 10 }; + + Assert.That(code: () => + Parallel.For(0L, 1000, options, (i, loopState) => + { + GlobalVariablesSource.Instance["global"].Add($"{i:0000}", new IntVariable((int)i)); + }), Throws.Nothing); + Assert.That(GlobalVariablesSource.Instance["global"].Count, Is.EqualTo(1000)); + + // Restore to saved value + SmartSettings.IsThreadSafeMode = savedIsThreadSafeMode; + } + } +} diff --git a/src/SmartFormat.Tests/Extensions/PersistentVariableSourceTests.cs b/src/SmartFormat.Tests/Extensions/PersistentVariableSourceTests.cs new file mode 100644 index 00000000..e743209e --- /dev/null +++ b/src/SmartFormat.Tests/Extensions/PersistentVariableSourceTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using SmartFormat.Core.Formatting; +using SmartFormat.Core.Settings; +using SmartFormat.Extensions; +using SmartFormat.Extensions.PersistentVariables; + +namespace SmartFormat.Tests.Extensions +{ + [TestFixture] + public class PersistentVariableSourceTests + { + [Test] + public void Is_Not_ReadOnly() + { + Assert.That(new PersistentVariablesSource().IsReadOnly, Is.False); + } + + [Test] + public void Add_And_Get_Items() + { + const string groupName1 = "global"; + const string groupName2 = "secret"; + + var pvs = new PersistentVariablesSource(); + var vg1 = new VariablesGroup(); + var vg2 = new VariablesGroup(); + + pvs.Add(groupName1, vg1); + pvs[groupName2] = vg2; + + Assert.That(pvs.Count, Is.EqualTo(2)); + Assert.That(pvs[groupName1], Is.EqualTo(vg1)); + Assert.That(pvs[groupName2], Is.EqualTo(vg2)); + Assert.That(pvs.Keys.Count, Is.EqualTo(2)); + Assert.That(pvs.Keys.Contains(groupName1)); + Assert.That(pvs.Values.Count, Is.EqualTo(2)); + Assert.That(pvs.Values.Contains(vg1)); + Assert.That(pvs.Values.Contains(vg2)); + Assert.That(pvs.ContainsKey(groupName2)); + Assert.That(pvs.TryGetValue(groupName1, out _), Is.True); + Assert.That(pvs.TryGetValue(groupName1 + "False", out _), Is.False); + } + + [Test] + public void Add_Key_Value_Pair() + { + var pvs = new PersistentVariablesSource(); + var kvp = new KeyValuePair("theKey", new VariablesGroup()); + pvs.Add(kvp); + + Assert.That(pvs.Contains(new KeyValuePair(kvp.Key, kvp.Value))); + } + + [Test] + public void KeyValuePairs_By_Enumerators() + { + var pvs = new PersistentVariablesSource(); + var kvp = new KeyValuePair("theKey", new VariablesGroup()); + pvs.Add(kvp); + var kvpFromEnumerator = pvs.FirstOrDefault(keyValuePair => keyValuePair.Key.Equals("theKey")); + + // Test GetEnumerator() + foreach (var keyValuePair in pvs) + { + Assert.That(keyValuePair, Is.EqualTo(kvp)); + } + + // Test IEnumerator> + Assert.That(kvpFromEnumerator, Is.EqualTo(kvp)); + } + + [Test] + public void Add_Item_With_Illegal_Name_Should_Throw() + { + var pvs = new PersistentVariablesSource(); + var vg = new VariablesGroup(); + + // Name must not be empty + Assert.That(code: () => vg.Add(string.Empty, new IntVariable(1)), Throws.ArgumentException); + Assert.That(code: () => pvs.Add(string.Empty, vg), Throws.ArgumentException); + } + + [Test] + public void Remove_Item() + { + var pvs = new PersistentVariablesSource(); + var kvp = new KeyValuePair("theKey", new VariablesGroup()); + pvs.Add(kvp); + pvs.Remove(kvp); + + Assert.That(pvs.Count, Is.EqualTo(0)); + Assert.That(pvs.Remove("non-existent"), Is.EqualTo(false)); + } + + [Test] + public void Remove_Key_Value_Pair() + { + var pvs = new PersistentVariablesSource { { "global", new VariablesGroup() } }; + pvs.Remove("global"); + + Assert.That(pvs.Count, Is.EqualTo(0)); + Assert.That(pvs.Remove("non-existent"), Is.EqualTo(false)); + } + + [Test] + public void Clear_All_Items() + { + var pvs = new PersistentVariablesSource(); + var kvp = new KeyValuePair("theKey", new VariablesGroup()); + pvs.Add(kvp); + pvs.Clear(); + + Assert.That(pvs.Count, Is.EqualTo(0)); + } + + [Test] + public void Copy_To_Array() + { + var pvs = new PersistentVariablesSource(); + var kvp1 = new KeyValuePair("key1", new VariablesGroup()); + var kvp2 = new KeyValuePair("key2", new VariablesGroup()); + pvs.Add(kvp1); + pvs.Add(kvp2); + + var array = new KeyValuePair[pvs.Count]; + pvs.CopyTo(array, 0); + + Assert.That(pvs.Count, Is.EqualTo(array.Length)); + for (var i = 0; i < array.Length; i++) + { + Assert.That(pvs.ContainsKey(array[i].Key)); + } + } + + [Test] + public void Shallow_Copy() + { + var pvs = new PersistentVariablesSource(); + + var kvp1 = new KeyValuePair("theKey1", new ObjectVariable("123")); + var kvp2 = new KeyValuePair("theKey2", new FloatVariable(987.654f)); + + var vg1 = new VariablesGroup { kvp1 }; + var vg2 = new VariablesGroup { kvp2 }; + + pvs.Add(vg1.First().Key, vg1); + pvs.Add(vg2.First().Key, vg2); + + var pvsCopy = pvs.Clone(); + + Assert.That(pvsCopy.Count, Is.EqualTo(pvs.Count)); + Assert.That(pvsCopy.Values, Is.EquivalentTo(pvs.Values)); + Assert.That(pvsCopy.Keys, Is.EquivalentTo(pvs.Keys)); + } + + [Test] + public void Use_Globals_Without_Args_To_Formatter() + { + // The top container + // It gets its name later, when being added to the PersistentVariablesSource + var varGroup = new VariablesGroup(); + + // Add a (nested) VariablesGroup named 'group' to the top container + varGroup.Add("group", new VariablesGroup + { + // Add variables to the nested group + { "groupString", new StringVariable("groupStringValue") }, + { "groupDateTime", new Variable(new DateTime(2024, 12, 31)) } + }); + // Add more variables to the top group container + varGroup.Add(new KeyValuePair("topInteger", new IntVariable(12345))); + var stringVar = new StringVariable("topStringValue"); + varGroup.Add("topString", stringVar); + + // The formatter for persistent variables requires only 2 extensions + var smart = new SmartFormatter(); + smart.FormatterExtensions.Add(new DefaultFormatter()); + // Add as the first in the source extensions list + var pvs = new PersistentVariablesSource + { + // Here, the top container gets its name + { "global", varGroup } + }; + smart.AddExtensions(0, pvs); + + // Act + // Note: We don't need args to the formatter for globals + var globalGroup = smart.Format(CultureInfo.InvariantCulture, + "{global.group.groupString} {global.group.groupDateTime:'groupDateTime='yyyy-MM-dd}"); + var topInteger = smart.Format("{global.topInteger}"); + var topString = smart.Format("{global.topString}"); + + // Assert + Assert.That(globalGroup, Is.EqualTo("groupStringValue groupDateTime=2024-12-31")); + Assert.That(topString, Is.EqualTo(stringVar.ToString())); + Assert.That(topInteger, Is.EqualTo("12345")); + } + + [Test] + public void NonExisting_GroupVariable_Should_Throw() + { + var varGrp = new VariablesGroup { { "existing", new StringVariable("existing-value") } }; + + var smart = new SmartFormatter(); + smart.FormatterExtensions.Add(new DefaultFormatter()); + smart.AddExtensions(0, new PersistentVariablesSource()); + var resultExisting = smart.Format("{existing}", varGrp); + + Assert.That(resultExisting, Is.EqualTo("existing-value")); + Assert.That(code: () => smart.Format("{non-existing}", varGrp), + Throws.InstanceOf().And.Message.Contains("non-existing")); + } + + [Test] + public void PersistentVariablesSource_NameGroupPair() + { + var pvs = new PersistentVariablesSource.NameGroupPair("theName", + new VariablesGroup + { new KeyValuePair("varName", new IntVariable(123)) }); + + Assert.That(pvs.Name, Is.EqualTo("theName")); + Assert.That(pvs.Group.ContainsKey("varName"), Is.True); + } + + [Test] + public void Format_Args_Should_Override_Persistent_Vars() + { + const string formatString = "{global.theVariable}"; + + // Setup PersistentVariablesSource + + var persistentGrp = new VariablesGroup + { { "theVariable", new StringVariable("val-from-persistent-source") } }; + + var smart = new SmartFormatter(); + smart.FormatterExtensions.Add(new DefaultFormatter()); + smart.AddExtensions(0, new PersistentVariablesSource {{"global", persistentGrp}}); + + // Setup equivalent VariablesGroup to use as an argument to Smart.Format(...) + + var argumentGrp = new VariablesGroup + { { "global", new VariablesGroup { { "theVariable", new StringVariable("val-from-argument") } } } }; + + // Arguments override PersistentVariablesSource + var argumentResult = smart.Format(formatString, argumentGrp); + // Without arguments, the variable from PersistentVariablesSource is used + var persistentResult = smart.Format(formatString); + + Assert.That(argumentResult, Is.EqualTo("val-from-argument")); + Assert.That(persistentResult, Is.EqualTo("val-from-persistent-source")); + } + + [Test] + public void Parallel_Load_By_Adding_Variables_To_Source() + { + // Switch to thread safety - otherwise the test would throw an InvalidOperationException + const bool currentThreadSafeMode = true; + var savedIsThreadSafeMode = SmartSettings.IsThreadSafeMode; + SmartSettings.IsThreadSafeMode = currentThreadSafeMode; + + var pvs = new PersistentVariablesSource { { "global", new VariablesGroup() } }; + var options = new ParallelOptions { MaxDegreeOfParallelism = 10 }; + + Assert.That(code: () => + Parallel.For(0L, 1000, options, (i, loopState) => + { + pvs["global"].Add($"{i:0000}", new IntVariable((int)i)); + }), Throws.Nothing); + Assert.That(pvs["global"].Count, Is.EqualTo(1000)); + + // Restore to saved value + SmartSettings.IsThreadSafeMode = savedIsThreadSafeMode; + } + } +} diff --git a/src/SmartFormat.Tests/Extensions/VariablesGroupTests.cs b/src/SmartFormat.Tests/Extensions/VariablesGroupTests.cs new file mode 100644 index 00000000..8f13ba1b --- /dev/null +++ b/src/SmartFormat.Tests/Extensions/VariablesGroupTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using SmartFormat.Core.Formatting; +using SmartFormat.Extensions; +using SmartFormat.Extensions.PersistentVariables; + +namespace SmartFormat.Tests.Extensions +{ + [TestFixture] + public class VariablesGroupTests + { + [Test] + public void Is_Not_ReadOnly() + { + Assert.That(new VariablesGroup().IsReadOnly, Is.False); + } + + [Test] + public void Add_And_Get_Items() + { + const string var1Name = "var1name"; + const string var2Name = "var2name"; + const string var3Name = "var3name"; + + var var1 = new IntVariable(1234); + var var2 = new StringVariable("theValue"); + var var3 = new BoolVariable(true); + + var vg = new VariablesGroup + { + { var1Name, var1 }, + new KeyValuePair(var2Name, var2) + }; + vg[var3Name] = var3; + + Assert.That(vg.Count, Is.EqualTo(3)); + Assert.That((int) vg[var1Name].GetValue()!, Is.EqualTo(1234)); + Assert.That((string) vg[var2Name].GetValue()!, Is.EqualTo("theValue")); + Assert.That(vg.Keys.Count, Is.EqualTo(3)); + Assert.That(vg.Keys.Contains(var1Name)); + Assert.That(vg.Values.Count, Is.EqualTo(3)); + Assert.That(vg.Values.Contains(var1)); + Assert.That(vg.Values.Contains(var2)); + Assert.That(vg.Values.Contains(var3)); + Assert.That(vg.ContainsKey(var2Name)); + Assert.That(vg.TryGetValue(var1Name + "False", out _), Is.False); + } + + [Test] + public void Add_Key_Value_Pair() + { + var vg = new VariablesGroup(); + var kvp = new KeyValuePair("theKey", new IntVariable(9876)); + vg.Add(kvp); + + Assert.That(vg.Contains(new KeyValuePair(kvp.Key, kvp.Value))); + } + + [Test] + public void KeyValuePairs_By_Enumerators() + { + var vg = new VariablesGroup(); + var kvp = new KeyValuePair("theKey", new IntVariable(9876)); + vg.Add(kvp); + var kvpFromEnumerator = vg.FirstOrDefault(keyValuePair => keyValuePair.Key.Equals("theKey")); + + // Test GetEnumerator() + foreach (var keyValuePair in vg) + { + Assert.That(keyValuePair, Is.EqualTo(kvp)); + } + + // Test IEnumerator> + Assert.That(kvpFromEnumerator, Is.EqualTo(kvp)); + } + + [Test] + public void Add_Item_With_Illegal_Name_Should_Throw() + { + var vg = new VariablesGroup(); + + // Name must not be empty + Assert.That(code: () => vg.Add(string.Empty, new IntVariable(1)), Throws.ArgumentException); + } + + [Test] + public void Remove_Item() + { + var vg = new VariablesGroup(); + var kvp = new KeyValuePair("theKey", new IntVariable(12)); + vg.Add(kvp); + vg.Remove(kvp); + + Assert.That(vg.Count, Is.EqualTo(0)); + Assert.That(vg.Remove("non-existent"), Is.EqualTo(false)); + } + + [Test] + public void Remove_Key_Value_Pair() + { + var vg = new VariablesGroup { { "theKey", new IntVariable(12) } }; + vg.Remove("theKey"); + + Assert.That(vg.Count, Is.EqualTo(0)); + Assert.That(vg.Remove("non-existent"), Is.EqualTo(false)); + } + + [Test] + public void Clear_All_Items() + { + var vg = new VariablesGroup(); + var kvp = new KeyValuePair("theKey", new IntVariable(135)); + vg.Add(kvp); + vg.Clear(); + + Assert.That(vg.Count, Is.EqualTo(0)); + } + + [Test] + public void Copy_To_Array() + { + var vg = new VariablesGroup(); + var kvp1 = new KeyValuePair("theKey1", new IntVariable(135)); + var kvp2 = new KeyValuePair("theKey2", new IntVariable(987)); + vg.Add(kvp1); + vg.Add(kvp2); + + var array = new KeyValuePair[vg.Count]; + vg.CopyTo(array, 0); + + Assert.That(vg.Count, Is.EqualTo(array.Length)); + for (var i = 0; i < array.Length; i++) + { + Assert.That(vg.ContainsKey(array[i].Key)); + } + } + + [Test] + public void Shallow_Copy() + { + var vg = new VariablesGroup(); + var kvp1 = new KeyValuePair("theKey1", new ObjectVariable("123")); + var kvp2 = new KeyValuePair("theKey2", new FloatVariable(987.654f)); + vg.Add(kvp1); + vg.Add(kvp2); + var vgCopy = vg.Clone(); + + Assert.That(vgCopy.Count, Is.EqualTo(vg.Count)); + Assert.That(vgCopy.Values, Is.EquivalentTo(vg.Values)); + Assert.That(vgCopy.Keys, Is.EquivalentTo(vg.Keys)); + } + + [Test] + public void NameVariablePair_Test() + { + var nv = new NameVariablePair("theName", new IntVariable(1234)); + + Assert.That(nv.Name, Is.EqualTo("theName")); + Assert.That((int) nv.Variable.GetValue()!, Is.EqualTo(1234)); + Assert.That(nv.ToString(), Is.EqualTo("'theName' - 'System.Int32' - '1234'")); + } + } +} diff --git a/src/SmartFormat.Tests/Pooling/ConcurrentPoolingTests.cs b/src/SmartFormat.Tests/Pooling/ConcurrentPoolingTests.cs index b6769431..025bd176 100644 --- a/src/SmartFormat.Tests/Pooling/ConcurrentPoolingTests.cs +++ b/src/SmartFormat.Tests/Pooling/ConcurrentPoolingTests.cs @@ -73,33 +73,25 @@ public void Parallel_Load_On_Specialized_Pools() SmartSettings.IsThreadSafeMode = currentThreadSafeMode; ResetAllPools(currentThreadSafeMode); - // Used to make sure the counter is consecutive across threads, - // because Interlocked.Read/Write cannot. - Semaphore semaphoreObject = new (initialCount: 1, maximumCount: 1); const int maxLoops = 100; var options = new ParallelOptions { MaxDegreeOfParallelism = 10 }; SmartSettings.IsThreadSafeMode = true; var list = new ConcurrentBag(); - long counter = 1; Assert.That(() => Parallel.For(1L, maxLoops, options, (i, loopState) => { - semaphoreObject.WaitOne(); - var threadLocalCounter = counter++; - semaphoreObject.Release(); - using var formatParsed = new Parser().ParseFormat("Number: {0:00000}"); var smart = new SmartFormatter(); smart.AddExtensions(new DefaultSource()); smart.AddExtensions(new DefaultFormatter()); - list.Add(smart.Format(formatParsed, threadLocalCounter)); + list.Add(smart.Format(formatParsed, i)); }), Throws.Nothing); var result = list.OrderBy(e => e); long compareCounter = 1; - Assert.That(counter, Is.EqualTo(maxLoops)); + Assert.That(list.Count, Is.EqualTo(maxLoops - 1)); Assert.That(result.All(r => r == $"Number: {compareCounter++:00000}")); foreach (var p in GetAllPoolCounters()) diff --git a/src/SmartFormat/Extensions/GlobalVariablesSource.cs b/src/SmartFormat/Extensions/GlobalVariablesSource.cs new file mode 100644 index 00000000..918371a2 --- /dev/null +++ b/src/SmartFormat/Extensions/GlobalVariablesSource.cs @@ -0,0 +1,53 @@ +// +// Copyright (C) axuno gGmbH, Scott Rippey, Bernhard Millauer and other contributors. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using SmartFormat.Core.Settings; +using SmartFormat.Extensions.PersistentVariables; + +namespace SmartFormat.Extensions +{ + /// + /// Provides global (static) variables of type to the + /// that do not need to be passed in as arguments when formatting a string. + /// The smart string should take the placeholder format like {groupName.variableName}. + /// Note: s from args to SmartFormatter.Format(...) take precedence over . + /// + public class GlobalVariablesSource : PersistentVariablesSource + { + private readonly IDictionary _globalGroupLookup = SmartSettings.IsThreadSafeMode + ? new ConcurrentDictionary() + : new Dictionary(); + + private static Lazy _lazySource = new(() => new GlobalVariablesSource(), + SmartSettings.IsThreadSafeMode + ? LazyThreadSafetyMode.PublicationOnly + : LazyThreadSafetyMode.None); + + private GlobalVariablesSource() + { + GroupLookup = _globalGroupLookup; + } + + /// + /// Initializes the current with a new, empty instance. + /// + public static void Reset() + { + _lazySource = new Lazy(() => new GlobalVariablesSource(), + SmartSettings.IsThreadSafeMode + ? LazyThreadSafetyMode.PublicationOnly + : LazyThreadSafetyMode.None); + } + + /// + /// Gets the static instance of the . + /// + public static GlobalVariablesSource Instance => _lazySource.Value; + } +} diff --git a/src/SmartFormat/Extensions/PersistentVariables/PersistentVariables.cs b/src/SmartFormat/Extensions/PersistentVariables/PersistentVariables.cs new file mode 100644 index 00000000..c4e22c96 --- /dev/null +++ b/src/SmartFormat/Extensions/PersistentVariables/PersistentVariables.cs @@ -0,0 +1,84 @@ +// +// Copyright (C) axuno gGmbH, Scott Rippey, Bernhard Millauer and other contributors. +// Licensed under the MIT license. + + +/* + Credits to Needle (https://github.com/needle-tools) + and their PersistentVariablesSource extension to Smart.Format + at https://github.com/needle-mirror/com.unity.localization/blob/master/Runtime/Smart%20Format/Extensions/PersistentVariablesSource.cs +*/ +namespace SmartFormat.Extensions.PersistentVariables +{ + /// + /// Base class for all single source variables. + /// + /// The value type to store in this variable. + public class Variable : IVariable + { + /// + /// Creates a new variable. + /// + /// The value of the variable. + public Variable(T? value) + { + Value = value; + } + + /// + /// The value for the . + /// + public T? Value { get; set; } + + /// + public object? GetValue() => Value; + + /// + public override string ToString() => Value?.ToString() ?? string.Empty; + } + + /// + /// A that holds a single float value. + /// + public class FloatVariable : Variable + { + /// + public FloatVariable(float? value) : base(value) {} + } + + /// + /// A that holds a single string value. + /// + public class StringVariable : Variable + { + /// + public StringVariable(string? value) : base(value) {} + } + + /// + /// A that holds a single integer value. + /// + public class IntVariable : Variable + { + /// + public IntVariable(int? value) : base(value) {} + } + + /// + /// A that holds a single boolean value. + /// + public class BoolVariable : Variable + { + /// + public BoolVariable(bool? value) : base(value) {} + } + + /// + /// A that holds an instance. + /// + public class ObjectVariable : Variable + { + /// + public ObjectVariable(object? value) : base(value) {} + } +} diff --git a/src/SmartFormat/Extensions/PersistentVariables/VariableInterfaces.cs b/src/SmartFormat/Extensions/PersistentVariables/VariableInterfaces.cs new file mode 100644 index 00000000..81906673 --- /dev/null +++ b/src/SmartFormat/Extensions/PersistentVariables/VariableInterfaces.cs @@ -0,0 +1,43 @@ +// +// Copyright (C) axuno gGmbH, Scott Rippey, Bernhard Millauer and other contributors. +// Licensed under the MIT license. + + +/* + Credits to Needle (https://github.com/needle-tools) + and their PersistentVariablesSource extension to Smart.Format + at https://github.com/needle-mirror/com.unity.localization/blob/master/Runtime/Smart%20Format/Extensions/PersistentVariablesSource.cs +*/ +using System; + +namespace SmartFormat.Extensions.PersistentVariables +{ + /// + /// Collection that contains . + /// + public interface IVariablesGroup + { + /// + /// Gets the variable with the matching key if one exists. + /// + /// The variable name to match. + /// The found variable or null if one could not be found. + /// if a variable could be found or if one could not. + bool TryGetValue(string name, out IVariable value); + } + + /// + /// Represents a variable that can be provided through a global + /// instead of as a SmartFormat argument. + /// A variable can be a single variable, in which case the value should be returned by + /// or a class with multiple variables () which can then be further extracted as SmartFormat arguments. + /// + public interface IVariable + { + /// + /// Gets the boxed into a . + /// + /// The boxed into a . + object? GetValue(); + } +} diff --git a/src/SmartFormat/Extensions/PersistentVariables/VariableNameValuePair.cs b/src/SmartFormat/Extensions/PersistentVariables/VariableNameValuePair.cs new file mode 100644 index 00000000..3d5c0be7 --- /dev/null +++ b/src/SmartFormat/Extensions/PersistentVariables/VariableNameValuePair.cs @@ -0,0 +1,45 @@ +// +// Copyright (C) axuno gGmbH, Scott Rippey, Bernhard Millauer and other contributors. +// Licensed under the MIT license. + + +/* + Credits to Needle (https://github.com/needle-tools) + and their PersistentVariablesSource extension to Smart.Format + at https://github.com/needle-mirror/com.unity.localization/blob/master/Runtime/Smart%20Format/Extensions/PersistentVariablesSource.cs +*/ +namespace SmartFormat.Extensions.PersistentVariables +{ + /// + /// The class for the variable name and its corresponding . + /// + internal class NameVariablePair + { + /// + /// Creates a new instance. + /// + /// The name of the variable. + /// The . + public NameVariablePair(string name, IVariable variable) + { + Name = name; + Variable = variable; + } + + /// + /// Gets the name of the variable. + /// + public string Name { get; } + + /// + /// Gets the that corresponds to the variable name. + /// + public IVariable Variable { get; } + + /// + /// Gets a string with the , and the value from . + /// + /// + public override string ToString() => $"'{Name}' - '{Variable.GetValue()?.GetType()}' - '{Variable.GetValue()}'"; + } +} \ No newline at end of file diff --git a/src/SmartFormat/Extensions/PersistentVariables/VariablesGroup.cs b/src/SmartFormat/Extensions/PersistentVariables/VariablesGroup.cs new file mode 100644 index 00000000..50879cde --- /dev/null +++ b/src/SmartFormat/Extensions/PersistentVariables/VariablesGroup.cs @@ -0,0 +1,204 @@ +// +// Copyright (C) axuno gGmbH, Scott Rippey, Bernhard Millauer and other contributors. +// Licensed under the MIT license. + + +/* + Credits to Needle (https://github.com/needle-tools) + and their PersistentVariablesSource extension to Smart.Format + at https://github.com/needle-mirror/com.unity.localization/blob/master/Runtime/Smart%20Format/Extensions/PersistentVariablesSource.cs +*/ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using SmartFormat.Core.Settings; + +namespace SmartFormat.Extensions.PersistentVariables +{ + /// + /// A collection of s that can be used + /// as arguments to the Format(...) overloads of , + /// or it can be added to a or a + ///
+ /// Each instance of keeps its own collection. + /// + /// One instance of can be used from different threads, + /// if is when creating the instance. + /// + ///
+ public class VariablesGroup : IVariablesGroup, IVariable, IDictionary + { + private readonly IDictionary _variableLookup = SmartSettings.IsThreadSafeMode + ? new ConcurrentDictionary() + : new Dictionary(); + + /// + /// + /// Gets the number of s in the group. + /// + public int Count => _variableLookup.Count; + + /// + /// Gets an containing all the variable names. + /// + public ICollection Keys => _variableLookup.Keys; + + /// + /// Gets all the s for this group. + /// + /// + /// Just implemented as part of . + /// + public ICollection Values => _variableLookup.Values.Select(s => s.Variable).ToList(); //NOSONAR + + /// + /// Always returns . + /// + /// + /// Just implemented as part of . + /// + public bool IsReadOnly => false; + + /// + /// Gets or sets the with the specified name. + /// + /// The name of the . + /// The found variable. + /// Thrown if a variable with the specified name does not exist. + public IVariable this[string name] + { + get => _variableLookup[name].Variable; + set => Add(name, value); + } + + /// + public object GetValue() => this; + + /// + /// Gets the with the specified name from this . + /// + /// The name of the variable. + /// The variable that was found or default. + /// if a variable was found and if one could not. + public bool TryGetValue(string name, out IVariable value) + { + if (_variableLookup.TryGetValue(name, out var v)) + { + value = v.Variable; + return true; + } + + value = default!; + + return false; + } + + /// + /// Adds a new to the group. + /// + /// The name of the variable, must be unique and must only contain selector characters which are also accepted by the . + /// The variable to use when formatting. See also , , , , . + /// Thrown if is null or empty. + public void Add(string name, IVariable variable) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Name must not be null or empty.", nameof(name)); + + var v = new NameVariablePair(name, variable); + _variableLookup.Add(name, v); + } + + /// + /// + /// + /// + public void Add(KeyValuePair item) => Add(item.Key, item.Value); + + /// + /// Removes an with the specified name. + /// + /// + /// if a variable with the specified name was removed, if one was not. + public bool Remove(string name) + { + if (_variableLookup.TryGetValue(name, out var v)) + { + return _variableLookup.Remove(name); + } + return false; + } + + /// + /// Removes an with the specified key. + /// + /// The item to be removed. Only the field will be considered. + /// if a variable with the specified name was removed, if one was not. + public bool Remove(KeyValuePair item) => Remove(item.Key); + + /// + /// Returns if a variable with the specified name exists. + /// + /// The variable name to check for. + /// if a matching variable could be found or if one could not. + public bool ContainsKey(string name) => _variableLookup.ContainsKey(name); + + /// + /// + /// + /// The item to check for. Both the Key and Value must match. + /// if a matching variable could be found or if one could not. + public bool Contains(KeyValuePair item) => TryGetValue(item.Key, out var v) && v == item.Value; + + /// + /// Copies the variables into an array starting at . + /// + /// The array to copy the variables into. + /// The index to start copying the items into. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + foreach (var entry in _variableLookup) + array[arrayIndex++] = new KeyValuePair(entry.Key, entry.Value.Variable); + } + + /// + /// Creates a Shallow Copy of this . + /// + /// A Shallow Copy of this . + public VariablesGroup Clone() + { + var vg = new VariablesGroup(); + foreach (var entry in _variableLookup) + vg.Add(entry.Key, entry.Value.Variable); + + return vg; + } + + /// + /// + /// + /// The enumerator that can be used to iterate through all the variables. + IEnumerator> IEnumerable>.GetEnumerator() + { + return _variableLookup.Select(v => new KeyValuePair(v.Key, v.Value.Variable)).GetEnumerator(); + } + + /// + /// Returns an enumerator for all variables in this group. + /// + /// The enumerator that can be used to iterate through all the variables. + public IEnumerator GetEnumerator() + { + return _variableLookup.Select(v => new KeyValuePair(v.Key, v.Value.Variable)).GetEnumerator(); + } + + /// + /// Removes all variables in the group. + /// + public void Clear() + { + _variableLookup.Clear(); + } + } +} diff --git a/src/SmartFormat/Extensions/PersistentVariablesSource.cs b/src/SmartFormat/Extensions/PersistentVariablesSource.cs new file mode 100644 index 00000000..8c16f5f1 --- /dev/null +++ b/src/SmartFormat/Extensions/PersistentVariablesSource.cs @@ -0,0 +1,246 @@ +// +// Copyright (C) axuno gGmbH, Scott Rippey, Bernhard Millauer and other contributors. +// Licensed under the MIT license. + + +/* + Credits to Needle (https://github.com/needle-tools) + and their PersistentVariablesSource extension to Smart.Format + at https://github.com/needle-mirror/com.unity.localization/blob/master/Runtime/Smart%20Format/Extensions/PersistentVariablesSource.cs +*/ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using SmartFormat.Core.Extensions; +using SmartFormat.Core.Settings; +using SmartFormat.Extensions.PersistentVariables; + +namespace SmartFormat.Extensions +{ + /// + /// Provides persistent variables of type to the + /// that do not need to be passed in as arguments when formatting a string. + /// The smart string should take the placeholder format like {groupName.variableName}. + /// Note: s from args to SmartFormatter.Format(...) take precedence over . + /// + public class PersistentVariablesSource : ISource, IDictionary + { + /// + /// Contains s and their name. + /// + protected internal class NameGroupPair + { + /// + /// CTOR. + /// + /// The name of the . + /// The . + public NameGroupPair(string name, VariablesGroup group) + { + Name = name; + Group = group; + } + + /// + /// Gets the name of the . + /// + public string Name { get; } + + /// + /// Gets the . + /// + public VariablesGroup Group { get; } + } + + /// + /// The container for s. + /// + protected IDictionary GroupLookup = SmartSettings.IsThreadSafeMode + ? new ConcurrentDictionary() + : new Dictionary(); + + /// + /// The number of stored variables. + /// + public int Count => GroupLookup.Values.Count; + + /// + /// Implemented as part of IDictionary. Will always return . + /// + public bool IsReadOnly => false; + + /// + /// Gets the names of stored s. + /// + public ICollection Keys => GroupLookup.Keys; + + /// + /// Gets the values of stored s. + /// + /// + /// Just implemented as part of . + /// + public ICollection Values => GroupLookup.Values.Select(k => k.Group).ToList(); //NOSONAR + + /// + /// Gets the that matches the . + /// + /// The name of the to return. + /// The that matches + public VariablesGroup this[string name] + { + get => GroupLookup[name].Group; + set => Add(name, value); + } + + /// + /// Returns if a could be found with a matching name, or if one could not. + /// + /// The name of the to find. + /// The found or default if one could not be found with a matching name. + /// if a group could be found or if one could not. + public bool TryGetValue(string name, out VariablesGroup value) + { + if (GroupLookup.TryGetValue(name, out var v)) + { + value = v.Group; + return true; + } + + value = default!; + return false; + } + + /// + /// Add a to the source. + /// + /// The name of the to add. + /// The to add. + /// Thrown if is null or empty. + public void Add(string name, VariablesGroup group) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Name must not be null or empty.", nameof(name)); + + var pair = new NameGroupPair(name, group); + + GroupLookup[name] = pair; + } + + /// + public void Add(KeyValuePair item) => Add(item.Key, item.Value); + + /// + /// Removes the with the matching name. + /// + /// The name of the to remove. + /// if a with a matching name was found and removed, or if one was not. + public bool Remove(string name) + { + if (GroupLookup.TryGetValue(name, out var v)) + { + GroupLookup.Remove(name); + return true; + } + + return false; + } + + /// + public bool Remove(KeyValuePair item) => Remove(item.Key); + + /// + /// Removes all s. + /// + public void Clear() + { + GroupLookup.Clear(); + } + + /// + /// Returns if a is found with the same name. + /// + /// The name of the global variable group to check for. + /// if a is found with the same name. + public bool ContainsKey(string name) => GroupLookup.ContainsKey(name); + + /// + public bool Contains(KeyValuePair item) => + TryGetValue(item.Key, out var v) && v == item.Value; + + /// + /// Copy all s into the provided array starting at . + /// + /// The array to copy the global variables into. + /// The index to start copying into. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + foreach (var entry in GroupLookup) + array[arrayIndex++] = new KeyValuePair(entry.Key, entry.Value.Group); + } + + /// + /// Creates a new instance of , respecting current , + /// with containing variables as a Shallow Copy. + /// + /// + /// A new instance of , respecting current , + /// with containing variables as a Shallow Copy. + /// + public PersistentVariablesSource Clone() + { + var pvs = new PersistentVariablesSource(); + foreach (var entry in GroupLookup) + pvs.Add(entry.Key, entry.Value.Group); + + return pvs; + } + + /// + /// Returns an for all the s in the source. + /// + /// + IEnumerator> IEnumerable>. + GetEnumerator() + { + return GroupLookup.Select(v => new KeyValuePair(v.Key, v.Value.Group)).GetEnumerator(); + } + + /// + /// Returns an for all s in the source. + /// + /// + public IEnumerator GetEnumerator() + { + return GroupLookup.Select(v => new KeyValuePair(v.Key, v.Value.Group)).GetEnumerator(); + } + + /// + public bool TryEvaluateSelector(ISelectorInfo selectorInfo) + { + // First, we test the current value + // If selectorInfo.SelectorOperator== string.Empty, the CurrentValue comes from an arg to the SmartFormatter.Format(...) + // IVariablesGroups from args have priority over PersistentVariablesSource + if (selectorInfo.CurrentValue is IVariablesGroup grp && TryEvaluateGroup(selectorInfo, grp)) + return true; + + if (TryGetValue(selectorInfo.SelectorText, out var group)) + { + selectorInfo.Result = group; + return true; + } + + return false; + } + + private static bool TryEvaluateGroup(ISelectorInfo selectorInfo, IVariablesGroup variablesGroup) + { + if (!variablesGroup.TryGetValue(selectorInfo.SelectorText, out var variable)) return false; + + selectorInfo.Result = variable.GetValue(); + return true; + } + } +}