From 546eb8d6cc3399787f8be9b6faa917faceb6386f Mon Sep 17 00:00:00 2001 From: Ygg01 Date: Fri, 12 Jan 2024 23:54:19 +0100 Subject: [PATCH] Make FluentBundle abstract and do some API refactoring (#50) * Fix execution * Make FluentBundle abstract * Minor refactors. * Add tests for Equality * Minor refactors * Add net8.0 for frozen sets * Add FrozenBundle * Finish frozen bundle --- Linguini.Bench/Linguini.Bench.csproj | 2 +- .../Linguini.Bundle.Test.csproj | 4 +- Linguini.Bundle.Test/Unit/BundleTests.cs | 97 ++- Linguini.Bundle.Test/Yaml/YamlSuiteParser.cs | 16 +- Linguini.Bundle/Builder/FluentBundleOption.cs | 2 +- Linguini.Bundle/Builder/LinguiniBuilder.cs | 16 +- Linguini.Bundle/ConcurrentBundle.cs | 186 ++++++ Linguini.Bundle/FluentBundle.cs | 550 +++++++----------- Linguini.Bundle/FrozenBundle.cs | 167 ++++++ .../LinguiniFluentFunction.cs | 2 +- Linguini.Bundle/IReadBundle.cs | 275 +++++++++ Linguini.Bundle/InsertBehavior.cs | 9 - Linguini.Bundle/Linguini.Bundle.csproj | 2 +- Linguini.Bundle/NonConcurrentBundle.cs | 182 ++++++ Linguini.Bundle/Resolver/ResolverHelpers.cs | 2 +- Linguini.Bundle/Resolver/Scope.cs | 46 +- Linguini.Bundle/Resolver/WriterHelpers.cs | 8 +- .../Linguini.Serialization.csproj | 2 +- Linguini.Shared/Linguini.Shared.csproj | 2 +- .../Linguini.Syntax.Tests.csproj | 2 +- Linguini.Syntax/Linguini.Syntax.csproj | 2 +- PluralRules.Test/Cldr/CldrParserTest.cs | 1 - PluralRules.Test/PluralRules.Test.csproj | 8 +- global.json | 2 +- 24 files changed, 1186 insertions(+), 399 deletions(-) create mode 100644 Linguini.Bundle/ConcurrentBundle.cs create mode 100644 Linguini.Bundle/FrozenBundle.cs rename Linguini.Bundle/{Func => Function}/LinguiniFluentFunction.cs (98%) create mode 100644 Linguini.Bundle/IReadBundle.cs delete mode 100644 Linguini.Bundle/InsertBehavior.cs create mode 100644 Linguini.Bundle/NonConcurrentBundle.cs diff --git a/Linguini.Bench/Linguini.Bench.csproj b/Linguini.Bench/Linguini.Bench.csproj index 471aca5..bfe1545 100644 --- a/Linguini.Bench/Linguini.Bench.csproj +++ b/Linguini.Bench/Linguini.Bench.csproj @@ -3,7 +3,7 @@ Exe false - net6.0 + net6.0;net8.0 diff --git a/Linguini.Bundle.Test/Linguini.Bundle.Test.csproj b/Linguini.Bundle.Test/Linguini.Bundle.Test.csproj index 2caf3f5..8ad1b66 100644 --- a/Linguini.Bundle.Test/Linguini.Bundle.Test.csproj +++ b/Linguini.Bundle.Test/Linguini.Bundle.Test.csproj @@ -5,13 +5,13 @@ enable Library 0.7.0 - net6.0 + net6.0;net8.0 - + diff --git a/Linguini.Bundle.Test/Unit/BundleTests.cs b/Linguini.Bundle.Test/Unit/BundleTests.cs index f8917b7..401ea50 100644 --- a/Linguini.Bundle.Test/Unit/BundleTests.cs +++ b/Linguini.Bundle.Test/Unit/BundleTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Threading.Tasks; @@ -22,23 +23,23 @@ public class BundleTests private readonly Func _formatter = _ => ""; private readonly Func _transform = str => str.ToUpper(CultureInfo.InvariantCulture); - private static string _res1 = @" + private const string Res1 = @" term = term .attr = 3"; - private static string _wrong = @" + private const string Wrong = @" term = 1"; - private static string _multi = @" + private const string Multi = @" term1 = val1 term2 = val2 .attr = 6"; - private static string _replace1 = @" + private const string Replace1 = @" term1 = val1 term2 = val2"; - private static string _replace2 = @" + private const string Replace2 = @" term1 = xxx new1 = new .attr = 6"; @@ -90,7 +91,7 @@ public void TestReplaceMessage() { var bundler = LinguiniBuilder.Builder() .CultureInfo(new CultureInfo("en")) - .AddResource(_replace1); + .AddResource(Replace1); var bundle = bundler.UncheckedBuild(); Assert.That(bundle.TryGetAttrMessage("term1", null, out _, out var termMsg)); @@ -98,7 +99,7 @@ public void TestReplaceMessage() Assert.That(bundle.TryGetAttrMessage("term2", null, out _, out var termMsg2)); Assert.That("val2", Is.EqualTo(termMsg2)); - bundle.AddResourceOverriding(_replace2); + bundle.AddResourceOverriding(Replace2); Assert.That(bundle.TryGetAttrMessage("term2", null, out _, out _)); Assert.That(bundle.TryGetAttrMessage("term1", null, out _, out termMsg)); Assert.That("xxx", Is.EqualTo(termMsg)); @@ -112,7 +113,7 @@ public void TestExceptions() { var bundler = LinguiniBuilder.Builder() .Locales("en-US", "sr-RS") - .AddResources(_wrong, _res1) + .AddResources(Wrong, Res1) .SetFormatterFunc(_formatter) .SetTransformFunc(_transform) .AddFunction("id", _idFunc) @@ -140,7 +141,7 @@ public void TestEnumeration() { var bundler = LinguiniBuilder.Builder() .Locale("en-US") - .AddResource(_multi) + .AddResource(Multi) .AddFunction("id", _idFunc) .AddFunction("zero", _zeroFunc) .UncheckedBuild(); @@ -176,11 +177,16 @@ public void TestConcurrencyOption() UseConcurrent = true, }; var optBundle = FluentBundle.MakeUnchecked(bundleOpt); + Parallel.For(0, 10, i => optBundle.AddResource($"term-1 = {i}", out _)); Parallel.For(0, 10, i => optBundle.AddResource($"term-2= {i}", out _)); Parallel.For(0, 10, i => optBundle.TryGetAttrMessage("term-1", null, out _, out _)); Parallel.For(0, 10, i => optBundle.AddResourceOverriding($"term-2= {i + 1}")); Assert.That(optBundle.HasMessage("term-1")); + + // Frozen bundle are read only and should be thread-safe + var frozenBundle = optBundle.ToFrozenBundle(); + Parallel.For(0, 10, i => frozenBundle.TryGetAttrMessage("term-1", null, out _, out _)); } [Test] @@ -208,7 +214,7 @@ public void TestFuncAddBehavior() bundle.TryAddFunction("id", _idFunc); Assert.That(bundle.TryAddFunction("id", _zeroFunc), Is.False); - Assert.Throws(() => bundle.AddFunctionUnchecked("id", _zeroFunc)); + Assert.Throws(() => bundle.AddFunctionUnchecked("id", _zeroFunc)); } [Test] @@ -221,7 +227,7 @@ public void TestBehavior(string idWithAttr, bool found) { var bundle = LinguiniBuilder.Builder() .CultureInfo(new CultureInfo("en")) - .AddResource(_replace2) + .AddResource(Replace2) .UncheckedBuild(); Assert.That(bundle.TryGetAttrMessage(idWithAttr, null, out _, out _), @@ -238,7 +244,7 @@ public void TestHasAttrMessage(string idWithAttr, bool found) { var bundle = LinguiniBuilder.Builder() .CultureInfo(new CultureInfo("en")) - .AddResource(_replace2) + .AddResource(Replace2) .UncheckedBuild(); Assert.That(bundle.TryGetAttrMessage(idWithAttr, null, out _, out _), @@ -252,7 +258,7 @@ public static IEnumerable TestBundleErrors yield return new TestCaseData("### Comment\r\nterm1") .Returns(new List { - new ErrorSpan(2, 13, 18, 18, 19) + new(2, 13, 18, 18, 19) }); } } @@ -264,6 +270,8 @@ public static IEnumerable TestBundleErrors var (_, error) = LinguiniBuilder.Builder().Locale("en-US") .AddResource(input) .Build(); + Debug.Assert(error != null, nameof(error) + " != null"); + Assert.That(error, Is.Not.Empty); return error.Select(e => e.GetSpan()).ToList(); } @@ -287,7 +295,7 @@ public void TestDynamicReference(string input) var (bundle, err) = LinguiniBuilder.Builder(useExperimental: true).Locale("en-US") .AddResource(input) .Build(); - Assert.That(err, Is.Empty); + Assert.That(err, Is.Null); var args = new Dictionary() { ["attacker"] = (FluentReference)"cat", @@ -312,18 +320,23 @@ [neuter] It [Test] [Parallelizable] - public void TestMacrosFail() + public void TestExtensionsWork() { var (bundle, err) = LinguiniBuilder.Builder(useExperimental: true).Locale("en-US") .AddResource(Macros) .Build(); - Assert.That(err, Is.Empty); + Assert.That(err, Is.Null); var args = new Dictionary { ["style"] = (FluentString)"chicago", }; Assert.That(bundle.TryGetMessage("call-attr-no-args", args, out _, out var message)); Assert.That("It", Is.EqualTo(message)); + + // Check Frozen bundle behaves similarly + var frozenBundle = bundle.ToFrozenBundle(); + Assert.That(frozenBundle.TryGetMessage("call-attr-no-args", args, out _, out var frozenMessage)); + Assert.That("It", Is.EqualTo(frozenMessage)); } private const string DynamicSelectors = @" -creature-fairy = fairy @@ -344,7 +357,7 @@ public void TestDynamicSelectors() .Locale("en-US") .AddResource(DynamicSelectors) .Build(); - Assert.That(err, Is.Empty); + Assert.That(err, Is.Null); var args = new Dictionary { ["object"] = (FluentReference)"creature-elf", @@ -357,6 +370,56 @@ public void TestDynamicSelectors() }; Assert.That(bundle.TryGetMessage("you-see", args, out _, out var message2)); Assert.That("You see a fairy.", Is.EqualTo(message2)); + + // Check Frozen bundle behaves similarly + var frozenBundle = bundle.ToFrozenBundle(); + args = new Dictionary + { + ["object"] = (FluentReference)"creature-elf", + }; + Assert.That(frozenBundle.TryGetMessage("you-see", args, out _, out var frozenMessage1)); + Assert.That("You see an elf.", Is.EqualTo(frozenMessage1)); + args = new Dictionary + { + ["object"] = (FluentReference)"creature-fairy", + }; + Assert.That(frozenBundle.TryGetMessage("you-see", args, out _, out var frozenMessage2)); + Assert.That("You see a fairy.", Is.EqualTo(frozenMessage2)); + } + + [Test] + public void TestDeepClone() + { + var originalBundleOption = new FluentBundleOption + { + Locales = { "en-US" }, + MaxPlaceable = 123, + UseIsolating = false, + TransformFunc = _transform, + FormatterFunc = _formatter, + Functions = new Dictionary() + { + ["zero"] = _zeroFunc, + ["id"] = _idFunc, + } + }; + + // Assume FluentBundle object has DeepClone method + FluentBundle originalBundle = FluentBundle.MakeUnchecked(originalBundleOption); + FluentBundle clonedBundle = originalBundle.DeepClone(); + + // Assert that the original and cloned objects are not the same reference + Assert.That(originalBundle, Is.Not.SameAs(clonedBundle)); + + // Assert that the properties are copied properly + Assert.That(originalBundle, Is.EqualTo(clonedBundle)); + + // Assert that if original property is changed, new property isn't. + originalBundle.AddFunctionOverriding("zero", _idFunc); + clonedBundle.TryGetFunction("zero", out var clonedZero); + Assert.That((FluentFunction) _zeroFunc, Is.EqualTo(clonedZero)); + originalBundle.TryGetFunction("zero", out var originalZero); + Assert.That((FluentFunction) _idFunc, Is.EqualTo(originalZero)); } } } \ No newline at end of file diff --git a/Linguini.Bundle.Test/Yaml/YamlSuiteParser.cs b/Linguini.Bundle.Test/Yaml/YamlSuiteParser.cs index 4b141b5..4b06ca4 100644 --- a/Linguini.Bundle.Test/Yaml/YamlSuiteParser.cs +++ b/Linguini.Bundle.Test/Yaml/YamlSuiteParser.cs @@ -3,7 +3,7 @@ using System.IO; using Linguini.Bundle.Builder; using Linguini.Bundle.Errors; -using Linguini.Bundle.Func; +using Linguini.Bundle.Function; using Linguini.Bundle.Types; using NUnit.Framework; using YamlDotNet.RepresentationModel; @@ -88,7 +88,10 @@ public void YamlTestSuiteMethod(ResolverTestSuite parsedTestSuite, LinguiniBuild foreach (var res in parsedTestSuite.Resources) { bundle.AddResource(res, out var err); - errors.AddRange(err); + if (err != null) + { + errors.AddRange(err); + } } if (parsedTestSuite.Bundle != null) @@ -147,7 +150,10 @@ public void YamlTestSuiteMethod(ResolverTestSuite parsedTestSuite, LinguiniBuild else { testBundle.AddResource(res, out var errs); - errors.AddRange(errs); + if (errs != null) + { + errors.AddRange(errs); + } } } } @@ -189,10 +195,10 @@ private static string GetFullPathFor(string file) private static void AssertErrorCases(List expectedErrors, - IList errs, + IList? errs, String testName) { - Assert.That(expectedErrors.Count, Is.EqualTo(errs.Count), testName); + Assert.That(expectedErrors.Count, Is.EqualTo(errs!.Count), testName); for (var i = 0; i < expectedErrors.Count; i++) { var actualError = errs[i]; diff --git a/Linguini.Bundle/Builder/FluentBundleOption.cs b/Linguini.Bundle/Builder/FluentBundleOption.cs index c0aff74..03fa4cb 100644 --- a/Linguini.Bundle/Builder/FluentBundleOption.cs +++ b/Linguini.Bundle/Builder/FluentBundleOption.cs @@ -12,7 +12,7 @@ public class FluentBundleOption public bool UseIsolating { get; init; } = true; public byte MaxPlaceable { get; init; } = 100; - public IList Locales { get; init; } = new List(); + public List Locales { get; init; } = new List(); public IDictionary Functions { get; init; } = new Dictionary(); diff --git a/Linguini.Bundle/Builder/LinguiniBuilder.cs b/Linguini.Bundle/Builder/LinguiniBuilder.cs index 6613f5b..a326402 100644 --- a/Linguini.Bundle/Builder/LinguiniBuilder.cs +++ b/Linguini.Bundle/Builder/LinguiniBuilder.cs @@ -48,7 +48,7 @@ public interface IBuildStep : IStep { FluentBundle UncheckedBuild(); - (FluentBundle, List) Build(); + (FluentBundle, List?) Build(); } public interface IReadyStep : IBuildStep @@ -65,7 +65,6 @@ private class StepBuilder : IReadyStep, ILocaleStep, IResourceStep private CultureInfo _culture; private readonly List _locales = new(); private readonly List _resources = new(); - private readonly List _source = new(); private bool _useIsolating; private Func? _formatterFunc; private Func? _transformFunc; @@ -113,7 +112,7 @@ public FluentBundle UncheckedBuild() { var (bundle, errors) = Build(); - if (errors.Count > 0) + if (errors is { Count: > 0 }) { throw new LinguiniException(errors); } @@ -121,7 +120,7 @@ public FluentBundle UncheckedBuild() return bundle; } - public (FluentBundle, List) Build() + public (FluentBundle, List?) Build() { var concurrent = new FluentBundleOption { @@ -134,18 +133,23 @@ public FluentBundle UncheckedBuild() }; var bundle = FluentBundle.MakeUnchecked(concurrent); bundle.Culture = _culture; + List? errors = null; - var errors = new List(); if (_functions.Count > 0) { bundle.AddFunctions(_functions, out var funcErr); - errors.AddRange(funcErr); + if (funcErr != null) + { + errors ??= new List(); + errors.AddRange(funcErr); + } } foreach (var resource in _resources) { if (!bundle.AddResource(resource,out var resErr)) { + errors ??= new List(); errors.AddRange(resErr); } } diff --git a/Linguini.Bundle/ConcurrentBundle.cs b/Linguini.Bundle/ConcurrentBundle.cs new file mode 100644 index 0000000..5c9daa7 --- /dev/null +++ b/Linguini.Bundle/ConcurrentBundle.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Linguini.Bundle.Errors; +using Linguini.Bundle.Types; +using Linguini.Shared.Types.Bundle; +using Linguini.Syntax.Ast; + +// ReSharper disable UnusedType.Global + +namespace Linguini.Bundle +{ + public sealed class ConcurrentBundle : FluentBundle, IEquatable + { + internal ConcurrentDictionary Functions = new(); + private ConcurrentDictionary _terms = new(); + private ConcurrentDictionary _messages = new(); + + public static ConcurrentBundle Thaw(FrozenBundle frozenBundle) + { + return new ConcurrentBundle + { + _messages = new ConcurrentDictionary(frozenBundle.Messages), + Functions = new ConcurrentDictionary(frozenBundle.Functions), + _terms = new ConcurrentDictionary(frozenBundle.Terms), + FormatterFunc = frozenBundle.FormatterFunc, + Locales = frozenBundle.Locales, + UseIsolating = frozenBundle.UseIsolating, + MaxPlaceable = frozenBundle.MaxPlaceable, + EnableExtensions = frozenBundle.EnableExtensions, + TransformFunc = frozenBundle.TransformFunc, + Culture = frozenBundle.Culture + }; + } + + /// + protected override void AddMessageOverriding(AstMessage message) + { + _messages[message.GetId()] = message; + } + + /// + protected override void AddTermOverriding(AstTerm term) + { + _terms[term.GetId()] = term; + } + + /// + protected override bool TryAddTerm(AstTerm term, List? errors) + { + if (_terms.TryAdd(term.GetId(), term)) return true; + errors ??= new List(); + errors.Add(new OverrideFluentError(term.GetId(), EntryKind.Term)); + return false; + } + + /// + protected override bool TryAddMessage(AstMessage message, List? errors) + { + if (_messages.TryAdd(message.GetId(), message)) return true; + errors ??= new List(); + errors.Add(new OverrideFluentError(message.GetId(), EntryKind.Message)); + return false; + } + + + /// + public override bool TryAddFunction(string funcName, ExternalFunction fluentFunction) + { + return Functions.TryAdd(funcName, fluentFunction); + } + + /// + public override void AddFunctionOverriding(string funcName, ExternalFunction fluentFunction) + { + Functions[funcName] = fluentFunction; + } + + /// + public override void AddFunctionUnchecked(string funcName, ExternalFunction fluentFunction) + { + if (Functions.TryAdd(funcName, fluentFunction)) return; + throw new ArgumentException($"Function with name {funcName} already exist"); + } + + /// + public override bool HasMessage(string identifier) + { + return _messages.ContainsKey(identifier); + } + + /// + public override bool TryGetAstMessage(string ident, [NotNullWhen(true)] out AstMessage? message) + { + return _messages.TryGetValue(ident, out message); + } + + /// + public override bool TryGetAstTerm(string ident, [NotNullWhen(true)] out AstTerm? term) + { + return _terms.TryGetValue(ident, out term); + } + + + /// + public override bool TryGetFunction(string funcName, [NotNullWhen(true)] out FluentFunction? function) + { + return Functions.TryGetValue(funcName, out function); + } + + /// + public override IEnumerable GetMessageEnumerable() + { + return _messages.Keys; + } + + /// + public override IEnumerable GetFuncEnumerable() + { + return Functions.Keys; + } + + /// + public override IEnumerable GetTermEnumerable() + { + return _terms.Keys; + } + + internal override IDictionary GetMessagesDictionary() + { + return _messages; + } + + internal override IDictionary GetTermsDictionary() + { + return _terms; + } + + internal override IDictionary GetFunctionDictionary() + { + return Functions; + } + + /// + public override FluentBundle DeepClone() + { + return new ConcurrentBundle + { + Functions = new ConcurrentDictionary(Functions), + _terms = new ConcurrentDictionary(_terms), + _messages = new ConcurrentDictionary(_messages), + Culture = (CultureInfo)Culture.Clone(), + Locales = new List(Locales), + UseIsolating = UseIsolating, + TransformFunc = (Func?)TransformFunc?.Clone(), + FormatterFunc = (Func?)FormatterFunc?.Clone(), + MaxPlaceable = MaxPlaceable, + EnableExtensions = EnableExtensions, + }; + } + + /// + public bool Equals(ConcurrentBundle? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return base.Equals(other) && Functions.SequenceEqual(other.Functions) && _terms.SequenceEqual(other._terms) && + _messages.SequenceEqual(other._messages); + } + + /// + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is ConcurrentBundle other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Functions, _terms, _messages); + } + } +} \ No newline at end of file diff --git a/Linguini.Bundle/FluentBundle.cs b/Linguini.Bundle/FluentBundle.cs index cd10cd6..04ed59e 100644 --- a/Linguini.Bundle/FluentBundle.cs +++ b/Linguini.Bundle/FluentBundle.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.ComponentModel.Design; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; -using System.Runtime.CompilerServices; +using System.Linq; using Linguini.Bundle.Builder; using Linguini.Bundle.Errors; using Linguini.Bundle.Resolver; @@ -16,143 +15,154 @@ namespace Linguini.Bundle { - using FluentArgs = IDictionary; - - public class FluentBundle + public abstract class FluentBundle : IEquatable, IReadBundle { - private IDictionary _funcList; - private IDictionary _terms; - private IDictionary _messages; - - #region Properties /// - /// of the bundle. Primary bundle locale + /// of the bundle. Primary bundle locale /// - public CultureInfo Culture { get; internal set; } + public CultureInfo Culture { get; internal set; } = CultureInfo.CurrentCulture; + /// - /// List of Locales. First element is primary bundle locale, others are fallback locales. + /// List of Locales. First element is primary bundle locale, others are fallback locales. /// - public List Locales { get; internal set; } + public List Locales { get; internal set; } = new(); + /// - /// When formatting patterns, FluentBundle inserts Unicode Directionality Isolation Marks to indicate that the direction of a placeable may differ from the surrounding message. - /// This is important for cases such as when a right-to-left user name is presented in the left-to-right message. + /// When formatting patterns, FluentBundle inserts Unicode Directionality Isolation Marks to indicate that the + /// direction of a placeable may differ from the surrounding message. + /// This is important for cases such as when a right-to-left user name is presented in the left-to-right message. /// - public bool UseIsolating { get; set; } + public bool UseIsolating { get; set; } = true; + /// - /// Specifies a method that will be applied only on values extending . Useful for defining a special formatter for . + /// Specifies a method that will be applied only on values extending . Useful for defining a + /// special formatter for . /// public Func? TransformFunc { get; set; } + /// - /// Specifies a method that will be applied only on values extending . Useful for defining a special formatter for . + /// Specifies a method that will be applied only on values extending . Useful for defining a + /// special formatter for . /// public Func? FormatterFunc { get; init; } + /// - /// Limit of placeable within one , when fully expanded (all nested elements count towards it). Useful for preventing billion laughs attack. Defaults to 100. + /// Limit of placeable within one , when fully expanded (all nested + /// elements count towards it). Useful for preventing billion laughs attack. Defaults to 100. /// - public byte MaxPlaceable { get; private init; } + public byte MaxPlaceable { get; internal init; } = 100; + /// - /// Whether experimental features are enabled. - /// - /// When `true` experimental features are enabled. Experimental features include stuff like: - /// - /// dynamic reference - /// dynamic reference attributes - /// term reference as parameters - /// + /// Whether experimental features are enabled. + /// When `true` experimental features are enabled. Experimental features include stuff like: + /// + /// dynamic reference + /// dynamic reference attributes + /// term reference as parameters + /// /// + // ReSharper disable once MemberCanBeProtected.Global public bool EnableExtensions { get; init; } - #endregion + /// + /// Determines if the provided identifier has a message associated with it. + /// + /// The identifier to check. + /// True if the identifier has a message; otherwise, false. + public abstract bool HasMessage(string identifier); + + /// + /// Tries to get the AstMessage associated with the specified ident. + /// + /// The identifier to look for. + /// + /// When this method returns, contains the AstMessage associated with the specified ident, if found; + /// otherwise, null. + /// + /// True if an AstMessage was found for the specified ident; otherwise, false. + public abstract bool TryGetAstMessage(string ident, [NotNullWhen(true)] out AstMessage? message); - #region Constructors + /// + /// Tries to get a term by its identifier. + /// + /// The identifier of the AST term. + /// + /// When this method returns, contains the AST term associated with the specified identifier, if the + /// identifier is found; otherwise, null. This parameter is passed uninitialized. + /// + /// true if the identifier is found and the corresponding AST term is retrieved; otherwise, false. + public abstract bool TryGetAstTerm(string ident, [NotNullWhen(true)] out AstTerm? term); - private FluentBundle() + /// + /// Tries to get the FluentFunction associated with the specified Identifier. + /// + /// The Identifier used to identify the FluentFunction. + /// + /// When the method returns, contains the FluentFunction associated with the Identifier, if the + /// Identifier is found; otherwise, null. This parameter is passed uninitialized. + /// + /// true if the FluentFunction associated with the Identifier is found; otherwise, false. + public bool TryGetFunction(Identifier id, [NotNullWhen(true)] out FluentFunction? function) { - _terms = new Dictionary(); - _messages = new Dictionary(); - _funcList = new Dictionary(); - Culture = CultureInfo.CurrentCulture; - Locales = new List(); - UseIsolating = true; - MaxPlaceable = 100; - EnableExtensions = false; + return TryGetFunction(id.ToString(), out function); } /// - /// Creates a new instance of FluentBundle with the specified options, possibly throwing exceptions if errors - /// are encountered. + /// Tries to retrieve a FluentFunction object by the given function name. /// - /// The options to configure the FluentBundle. - /// A new instance of FluentBundle with the specified options. - public static FluentBundle MakeUnchecked(FluentBundleOption option) - { - var bundle = ConstructBundle(option); - bundle.AddFunctions(option.Functions, out _); - return bundle; - } + /// The name of the function to retrieve. + /// An output parameter that will hold the retrieved FluentFunction object, if found. + /// + /// True if a FluentFunction object with the specified name was found and assigned to the function output parameter. + /// False if a FluentFunction object with the specified name was not found. + /// + public abstract bool TryGetFunction(string funcName, [NotNullWhen(true)] out FluentFunction? function); - private static FluentBundle ConstructBundle(FluentBundleOption option) + public string FormatPattern(Pattern pattern, IDictionary? args, + [NotNullWhen(false)] out IList? errors) { - var primaryLocale = option.Locales.Count > 0 - ? option.Locales[0] - : CultureInfo.CurrentCulture.Name; - var cultureInfo = new CultureInfo(primaryLocale, false); - var locales = new List { primaryLocale }; - IDictionary terms; - IDictionary messages; - IDictionary functions; - if (option.UseConcurrent) - { - terms = new ConcurrentDictionary(); - messages = new ConcurrentDictionary(); - functions = new ConcurrentDictionary(); - } - else - { - terms = new Dictionary(); - messages = new Dictionary(); - functions = new Dictionary(); - } - - return new FluentBundle - { - Culture = cultureInfo, - Locales = locales, - _terms = terms, - _messages = messages, - _funcList = functions, - TransformFunc = option.TransformFunc, - FormatterFunc = option.FormatterFunc, - UseIsolating = option.UseIsolating, - MaxPlaceable = option.MaxPlaceable, - EnableExtensions = option.EnableExtensions, - }; + var scope = new Scope(this, args); + var value = pattern.Resolve(scope); + errors = scope.Errors; + return value.AsString(); } - #endregion + /// + /// This method retrieves an enumerable collection of all message identifiers. + /// + /// + /// An enumerable collection of message identifiers. + /// + public abstract IEnumerable GetMessageEnumerable(); - #region AddMethods + /// + /// Retrieves an enumerable collection of string function names. + /// + /// An enumerable collection of functions names. + public abstract IEnumerable GetFuncEnumerable(); - public bool AddResource(string input, out List errors) + /// + /// Retrieves an enumerable collection of terms. + /// + /// An enumerable collection of terms. + public abstract IEnumerable GetTermEnumerable(); + + public bool AddResource(string input, [NotNullWhen(false)] out List? errors) { var res = new LinguiniParser(input, EnableExtensions).Parse(); return AddResource(res, out errors); } - - public bool AddResource(TextReader reader, out List errors) + + public bool AddResource(TextReader reader, [NotNullWhen(false)] out List? errors) { var res = new LinguiniParser(reader, EnableExtensions).Parse(); return AddResource(res, out errors); } - - internal bool AddResource(Resource res, out List errors) + public bool AddResource(Resource res, [NotNullWhen(false)] out List? errors) { - errors = new List(); - foreach (var parseError in res.Errors) - { - errors.Add(ParserFluentError.ParseError(parseError)); - } + var innerErrors = new List(); + foreach (var parseError in res.Errors) innerErrors.Add(ParserFluentError.ParseError(parseError)); for (var entryPos = 0; entryPos < res.Entries.Count; entryPos++) { @@ -160,22 +170,37 @@ internal bool AddResource(Resource res, out List errors) switch (entry) { case AstMessage message: - AddMessage(errors, message); + TryAddMessage(message, innerErrors); break; case AstTerm term: - AddTerm(errors, term); + TryAddTerm(term, innerErrors); break; } } - if (errors.Count == 0) + if (innerErrors.Count == 0) { + errors = null; return true; } + errors = innerErrors; return false; } + + /// + /// Adds the given AstMessage to the collection of messages, by overriding any existing messages with the same name. + /// + /// The AstMessage to be added. + protected abstract void AddMessageOverriding(AstMessage message); + + /// + /// Adds a term to the AstTerm list, overriding any existing term with the same name. + /// + /// The term to be added. + protected abstract void AddTermOverriding(AstTerm term); + private void InternalResourceOverriding(Resource resource) { for (var entryPos = 0; entryPos < resource.Entries.Count; entryPos++) @@ -194,280 +219,133 @@ private void InternalResourceOverriding(Resource resource) } } - private void AddMessageOverriding(AstMessage message) - { - _messages[message.GetId()] = message; - } - - private void AddTermOverriding(AstTerm term) - { - _terms[term.GetId()] = term; - } - + /// + /// Adds a resource. + /// Any messages or terms in bundle will be overriden by the existing ones. + /// + /// The input string containing the resource data. public void AddResourceOverriding(string input) { var res = new LinguiniParser(input, EnableExtensions).Parse(); InternalResourceOverriding(res); } - + public void AddResourceOverriding(TextReader input) { var res = new LinguiniParser(input, EnableExtensions).Parse(); InternalResourceOverriding(res); } - private void AddTerm(List errors, AstTerm term) - { - var termId = term.GetId(); - // ReSharper disable once CanSimplifyDictionaryLookupWithTryAdd - // Using TryAdd here leads to undocumented exceptions - if (_terms.ContainsKey(termId)) - { - errors.Add(new OverrideFluentError(termId, EntryKind.Term)); - } - else - { - _terms[termId] = term; - } - } - - private void AddMessage(List errors, AstMessage msg) - { - var msgId = msg.GetId(); - // ReSharper disable once CanSimplifyDictionaryLookupWithTryAdd - // Using TryAdd here leads to undocumented exceptions - if (_messages.ContainsKey(msgId)) - { - errors.Add(new OverrideFluentError(msg.GetId(), EntryKind.Message)); - } - else - { - _messages[msgId] = msg; - } - } - - public bool TryAddFunction(string funcName, ExternalFunction fluentFunction) - { - return TryInsert(funcName, fluentFunction, InsertBehavior.None); - } - - public bool AddFunctionOverriding(string funcName, ExternalFunction fluentFunction) - { - return TryInsert(funcName, fluentFunction, InsertBehavior.OverwriteExisting); - } - - public bool AddFunctionUnchecked(string funcName, ExternalFunction fluentFunction) - { - return TryInsert(funcName, fluentFunction); - } - - public void AddFunctions(IDictionary functions, out List errors) - { - errors = new List(); - foreach (var keyValue in functions) - { - if (!TryInsert(keyValue.Key, keyValue.Value, InsertBehavior.None)) - { - errors.Add(new OverrideFluentError(keyValue.Key, EntryKind.Func)); - } - } - } - - private bool TryInsert(string funcName, ExternalFunction fluentFunction, - InsertBehavior behavior = InsertBehavior.ThrowOnExisting) - { - switch (behavior) - { - case InsertBehavior.None: - if (!_funcList.ContainsKey(funcName)) - { - _funcList.Add(funcName, fluentFunction); - return true; - } - - return false; - case InsertBehavior.OverwriteExisting: - _funcList[funcName] = fluentFunction; - break; - default: - if (_funcList.ContainsKey(funcName)) - { - throw new KeyNotFoundException($"Function {funcName} already exists!"); - } - - _funcList.Add(funcName, fluentFunction); - break; - } - - return true; - } - - #endregion - - #region AttrMessage + protected abstract bool TryAddTerm(AstTerm term, [NotNullWhen(false)] List? errors); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool HasMessage(string identifier) - { - return _messages.ContainsKey(identifier); - } + protected abstract bool TryAddMessage(AstMessage msg, [NotNullWhen(false)] List? errors); - public bool HasAttrMessage(string idWithAttr) - { - var attributes = idWithAttr.IndexOf('.'); - if (attributes < 0) - { - return HasMessage(idWithAttr); - } - var id = idWithAttr.AsSpan(0, attributes).ToString(); - var attr = idWithAttr.AsSpan(attributes + 1).ToString(); - if (TryGetAstMessage(id, out var astMessage)) - { - return astMessage.GetAttribute(attr) != null; - } + public abstract bool TryAddFunction(string funcName, ExternalFunction fluentFunction); - return false; - } + public abstract void AddFunctionOverriding(string funcName, ExternalFunction fluentFunction); - public string? GetAttrMessage(string msgWithAttr, FluentArgs? args = null) - { - TryGetAttrMessage(msgWithAttr, args, out var errors, out var message); - if (errors.Count > 0) - { - throw new LinguiniException(errors); - } + public abstract void AddFunctionUnchecked(string funcName, ExternalFunction fluentFunction); + + internal abstract IDictionary GetMessagesDictionary(); + internal abstract IDictionary GetTermsDictionary(); + internal abstract IDictionary GetFunctionDictionary(); - return message; - } + /// + /// Creates a deep clone of the current instance of the AbstractFluentBundle class. + /// + /// A new instance of the AbstractFluentBundle class that is a deep clone of the current instance. + public abstract FluentBundle DeepClone(); - public string? GetAttrMessage(string msgWithAttr, params (string, IFluentType)[] args) + /// + /// Adds a collection of external functions to the dictionary of available functions. + /// If any function cannot be added, the errors are returned in the list. + /// + /// A dictionary of function names and their corresponding ExternalFunction objects. + /// A list of errors indicating functions that could not be added. + public void AddFunctions(IDictionary functions, out List? errors) { - var dictionary = new Dictionary(args.Length); - foreach (var (key, val) in args) - { - dictionary.Add(key, val); - } - - TryGetAttrMessage(msgWithAttr, dictionary, out var errors, out var message); - if (errors.Count > 0) - { - throw new LinguiniException(errors); - } - - return message; + errors = new List(); + foreach (var keyValue in functions) + if (!TryAddFunction(keyValue.Key, keyValue.Value)) + errors.Add(new OverrideFluentError(keyValue.Key, EntryKind.Func)); } - public bool TryGetAttrMessage(string msgWithAttr, FluentArgs? args, - out IList errors, out string? message) - { - if (msgWithAttr.Contains(".")) - { - var split = msgWithAttr.Split('.'); - return TryGetMessage(split[0], split[1], args, out errors, out message); - } - return TryGetMessage(msgWithAttr, null, args, out errors, out message); - } - public bool TryGetMessage(string id, FluentArgs? args, - out IList errors, [NotNullWhen(true)] out string? message) - => TryGetMessage(id, null, args, out errors, out message); - - public bool TryGetMessage(string id, string? attribute, FluentArgs? args, - out IList errors, [NotNullWhen(true)] out string? message) + /// + /// Creates a FluentBundle object with the specified options. + /// + /// The FluentBundleOption object that contains the options for creating the FluentBundle + /// A FluentBundle object created with the specified options + public static FluentBundle MakeUnchecked(FluentBundleOption option) { - string? value = null; - errors = new List(); - - if (TryGetAstMessage(id, out var astMessage)) + var primaryLocale = option.Locales.Count > 0 + ? option.Locales[0] + : CultureInfo.CurrentCulture.Name; + var cultureInfo = new CultureInfo(primaryLocale, false); + var func = option.Functions.ToDictionary(x => x.Key, x => (FluentFunction)x.Value); + return option.UseConcurrent switch { - var pattern = attribute != null - ? astMessage.GetAttribute(attribute)?.Value - : astMessage.Value; - - if (pattern == null) + true => new ConcurrentBundle { - var msg = (attribute == null) - ? id - : $"{id}.{attribute}"; - errors.Add(ResolverFluentError.NoValue($"{msg}")); - message = FluentNone.None.ToString(); - return false; + Locales = option.Locales, + Culture = cultureInfo, + EnableExtensions = option.EnableExtensions, + FormatterFunc = option.FormatterFunc, + TransformFunc = option.TransformFunc, + MaxPlaceable = option.MaxPlaceable, + UseIsolating = option.UseIsolating, + Functions = new ConcurrentDictionary(func) + }, + _ => new NonConcurrentBundle + { + Locales = option.Locales, + Culture = cultureInfo, + EnableExtensions = option.EnableExtensions, + FormatterFunc = option.FormatterFunc, + TransformFunc = option.TransformFunc, + MaxPlaceable = option.MaxPlaceable, + UseIsolating = option.UseIsolating, + Functions = func } - - value = FormatPattern(pattern, args, out errors); - } - - message = value; - return message != null; - } - - public bool TryGetAstMessage(string ident, [NotNullWhen(true)] out AstMessage? message) - { - return _messages.TryGetValue(ident, out message); - } - - public bool TryGetAstTerm(string ident, [NotNullWhen(true)] out AstTerm? term) - { - return _terms.TryGetValue(ident, out term); - } - - public bool TryGetFunction(Identifier id, [NotNullWhen(true)] out FluentFunction? function) - { - return TryGetFunction(id.ToString(), out function); - } - - public bool TryGetFunction(string funcName, [NotNullWhen(true)] out FluentFunction? function) - { - if (_funcList.ContainsKey(funcName)) - { - return _funcList.TryGetValue(funcName, out function); - } - - function = null; - return false; - } - - #endregion - - public string FormatPattern(Pattern pattern, FluentArgs? args, - out IList errors) - { - var scope = new Scope(this, args); - var value = pattern.Resolve(scope); - errors = scope.Errors; - return value.AsString(); + }; } - public IEnumerable GetMessageEnumerable() + /// + /// Converts the current object to a . + /// + /// A new instance of FrozenBundle. + public FrozenBundle ToFrozenBundle() { - return _messages.Keys; + return new FrozenBundle(this); } - public IEnumerable GetFuncEnumerable() + /// + public bool Equals(FluentBundle? other) { - return _funcList.Keys; + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Culture.Equals(other.Culture) && Locales.SequenceEqual(other.Locales) && + UseIsolating == other.UseIsolating && Equals(TransformFunc, other.TransformFunc) && + Equals(FormatterFunc, other.FormatterFunc) && MaxPlaceable == other.MaxPlaceable && + EnableExtensions == other.EnableExtensions; } - - public IEnumerable GetTermEnumerable() + + /// + public override bool Equals(object? obj) { - return _terms.Keys; + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((FluentBundle)obj); } - public FluentBundle DeepClone() + /// + public override int GetHashCode() { - return new() - { - Culture = (CultureInfo)Culture.Clone(), - FormatterFunc = FormatterFunc, - Locales = new List(Locales), - _messages = new Dictionary(_messages), - _terms = new Dictionary(_terms), - _funcList = new Dictionary(_funcList), - TransformFunc = TransformFunc, - UseIsolating = UseIsolating, - }; + return HashCode.Combine(Culture, Locales, UseIsolating, TransformFunc, FormatterFunc, MaxPlaceable, + EnableExtensions); } } } \ No newline at end of file diff --git a/Linguini.Bundle/FrozenBundle.cs b/Linguini.Bundle/FrozenBundle.cs new file mode 100644 index 0000000..ed5b3dc --- /dev/null +++ b/Linguini.Bundle/FrozenBundle.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Linguini.Bundle.Errors; +using Linguini.Bundle.Resolver; +using Linguini.Bundle.Types; +using Linguini.Shared.Types.Bundle; +using Linguini.Syntax.Ast; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#elif NET6_0_OR_GREATER +using System.Collections.Immutable; +#endif + + +namespace Linguini.Bundle +{ + /// + /// Represents a frozen bundle i.e. a bundle to which no items can be added or removed. + /// + /// It properly works on net8.0 utilizing its FrozenDictionary implementation. + /// On other platforms it uses a very naive polyfill. + /// Frozen bundle implements the interface. + /// + public class FrozenBundle : IReadBundle + { + /// + /// of the bundle. Primary bundle locale + /// + public CultureInfo Culture { get; } + + /// + /// List of Locales. First element is primary bundle locale, others are fallback locales. + /// + public List Locales { get; init; } + + /// + /// When formatting patterns, FluentBundle inserts Unicode Directionality Isolation Marks to indicate that the direction of a placeable may differ from the surrounding message. + /// This is important for cases such as when a right-to-left user name is presented in the left-to-right message. + /// + public bool UseIsolating { get; } + + /// + /// Specifies a method that will be applied only on values extending . Useful for defining a special formatter for . + /// + public Func? FormatterFunc { get; } + + /// + /// Limit of placeable within one , when fully expanded (all nested elements count towards it). Useful for preventing billion laughs attack. Defaults to 100. + /// + public byte MaxPlaceable { get; } + + /// + /// Whether experimental features are enabled. + /// + /// When `true` experimental features are enabled. Experimental features include stuff like: + /// + /// dynamic reference + /// dynamic reference attributes + /// term reference as parameters + /// + /// + // ReSharper disable once MemberCanBeProtected.Global + public bool EnableExtensions { get; init; } + + /// + /// Specifies a method that will be applied only on values extending . Useful for defining a special formatter for . + /// + public Func? TransformFunc { get; } + + internal readonly IDictionary Functions; + internal readonly IDictionary Terms; + internal readonly IDictionary Messages; +#if NET8_0_OR_GREATER + internal FrozenBundle(FluentBundle bundle) + { + Culture = bundle.Culture; + MaxPlaceable = bundle.MaxPlaceable; + Locales = bundle.Locales; + UseIsolating = bundle.UseIsolating; + EnableExtensions = bundle.EnableExtensions; + FormatterFunc = bundle.FormatterFunc; + TransformFunc = bundle.TransformFunc; + Messages = bundle.GetMessagesDictionary().ToFrozenDictionary(); + Terms = bundle.GetTermsDictionary().ToFrozenDictionary(); + Functions = bundle.GetFunctionDictionary().ToFrozenDictionary(); + } +#elif NET6_0_OR_GREATER + internal FrozenBundle(FluentBundle bundle) + { + Culture = bundle.Culture; + MaxPlaceable = bundle.MaxPlaceable; + Locales = bundle.Locales; + UseIsolating = bundle.UseIsolating; + EnableExtensions = bundle.EnableExtensions; + FormatterFunc = bundle.FormatterFunc; + TransformFunc = bundle.TransformFunc; + Messages = bundle.GetMessagesDictionary().ToImmutableDictionary(); + Terms = bundle.GetTermsDictionary().ToImmutableDictionary(); + Functions = bundle.GetFunctionDictionary().ToImmutableDictionary(); + } +#else + internal FrozenBundle(FluentBundle bundle) + { + Culture = bundle.Culture; + MaxPlaceable = bundle.MaxPlaceable; + Locales = bundle.Locales; + UseIsolating = bundle.UseIsolating; + EnableExtensions = bundle.EnableExtensions; + FormatterFunc = bundle.FormatterFunc; + TransformFunc = bundle.TransformFunc; + Messages = new Dictionary(bundle.GetMessagesDictionary()); + Terms = new Dictionary(bundle.GetTermsDictionary()); + Functions = new Dictionary(bundle.GetFunctionDictionary()); + } +#endif + public bool HasMessage(string identifier) + { + return Messages.ContainsKey(identifier); + } + + public string FormatPattern(Pattern pattern, IDictionary? args, + [NotNullWhen(false)] out IList? errors) + { + var scope = new Scope(this, args); + var value = pattern.Resolve(scope); + errors = scope.Errors; + return value.AsString(); + } + + public bool TryGetAstMessage(string ident, [NotNullWhen(true)] out AstMessage? message) + { + return Messages.TryGetValue(ident, out message); + } + + public bool TryGetAstTerm(string ident, [NotNullWhen(true)] out AstTerm? term) + { + return Terms.TryGetValue(ident, out term); + } + + public bool TryGetFunction(Identifier id, [NotNullWhen(true)] out FluentFunction? function) + { + return Functions.TryGetValue(id.ToString(), out function); + } + + public bool TryGetFunction(string funcName, [NotNullWhen(true)] out FluentFunction? function) + { + return Functions.TryGetValue(funcName, out function); + } + + public IEnumerable GetMessageEnumerable() + { + return Messages.Keys; + } + + public IEnumerable GetFuncEnumerable() + { + return Functions.Keys; + } + + public IEnumerable GetTermEnumerable() + { + return Terms.Keys; + } + } +} \ No newline at end of file diff --git a/Linguini.Bundle/Func/LinguiniFluentFunction.cs b/Linguini.Bundle/Function/LinguiniFluentFunction.cs similarity index 98% rename from Linguini.Bundle/Func/LinguiniFluentFunction.cs rename to Linguini.Bundle/Function/LinguiniFluentFunction.cs index 5eac61d..12fca9b 100644 --- a/Linguini.Bundle/Func/LinguiniFluentFunction.cs +++ b/Linguini.Bundle/Function/LinguiniFluentFunction.cs @@ -5,7 +5,7 @@ using Linguini.Bundle.Types; using Linguini.Shared.Types.Bundle; -namespace Linguini.Bundle.Func +namespace Linguini.Bundle.Function { public static class LinguiniFluentFunctions { diff --git a/Linguini.Bundle/IReadBundle.cs b/Linguini.Bundle/IReadBundle.cs new file mode 100644 index 0000000..e4415f5 --- /dev/null +++ b/Linguini.Bundle/IReadBundle.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Linguini.Bundle.Errors; +using Linguini.Bundle.Types; +using Linguini.Shared.Types.Bundle; +using Linguini.Syntax.Ast; + +namespace Linguini.Bundle +{ + public interface IReadBundle + { + /// + /// Determines if the provided identifier has a message associated with it. + /// The identifier to check. + /// True if the identifier has a message; otherwise, false. + /// + bool HasMessage(string identifier); + + string FormatPattern(Pattern pattern, IDictionary? args, + [NotNullWhen(false)] out IList? errors); + + /// + /// Tries to get the AstMessage associated with the specified ident. + /// + /// The identifier to look for. + /// When this method returns, contains the AstMessage associated with the specified ident, if found; otherwise, null. + /// True if an AstMessage was found for the specified ident; otherwise, false. + bool TryGetAstMessage(string ident, [NotNullWhen(true)] out AstMessage? message); + + /// + /// Tries to get a term by its identifier. + /// + /// The identifier of the AST term. + /// When this method returns, contains the AST term associated with the specified identifier, if the identifier is found; otherwise, null. This parameter is passed uninitialized. + /// true if the identifier is found and the corresponding AST term is retrieved; otherwise, false. + bool TryGetAstTerm(string ident, [NotNullWhen(true)] out AstTerm? term); + + /// + /// Tries to get the FluentFunction associated with the specified Identifier. + /// + /// The Identifier used to identify the FluentFunction. + /// When the method returns, contains the FluentFunction associated with the Identifier, if the Identifier is found; otherwise, null. This parameter is passed uninitialized. + /// true if the FluentFunction associated with the Identifier is found; otherwise, false. + bool TryGetFunction(Identifier id, [NotNullWhen(true)] out FluentFunction? function); + + /// + /// Tries to retrieve a FluentFunction object by the given function name. + /// + /// The name of the function to retrieve. + /// An output parameter that will hold the retrieved FluentFunction object, if found. + /// + /// True if a FluentFunction object with the specified name was found and assigned to the function output parameter. + /// False if a FluentFunction object with the specified name was not found. + /// + bool TryGetFunction(string funcName, [NotNullWhen(true)] out FluentFunction? function); + + /// + /// This method retrieves an enumerable collection of all message identifiers. + /// + /// An enumerable collection of message identifiers. + IEnumerable GetMessageEnumerable(); + + /// + /// Retrieves an enumerable collection of string function names. + /// + /// An enumerable collection of functions names. + IEnumerable GetFuncEnumerable(); + + /// + /// Retrieves an enumerable collection of terms. + /// + /// An enumerable collection of terms. + IEnumerable GetTermEnumerable(); + + /// + /// Tries to retrieve a message based on the provided ID and arguments. + /// A convenience method for + /// + /// The ID of the message to retrieve. + /// Optional. A dictionary of arguments to be inserted into the message. + /// Optional. When the method returns false, a list of errors encountered during the retrieval process. + /// Optional. When the method returns true, the retrieved message. Null if no message is found. + /// + /// True if the message was successfully retrieved. False otherwise. + /// + bool TryGetMessage(string id, IDictionary? args, + [NotNullWhen(false)] out IList? errors, [NotNullWhen(true)] out string? message) + { + return this.TryGetMessage(id, null, args, out errors, out message); + } + + + /// + /// Determines whether the given identifier with attribute has a message. + /// + /// The identifier with attribute. + /// True if the identifier with attribute has a message; otherwise, false. + bool HasAttrMessage(string idWithAttr) + { + var attributes = idWithAttr.IndexOf('.'); + if (attributes < 0) + { + return HasMessage(idWithAttr); + } + + var id = idWithAttr.AsSpan(0, attributes).ToString(); + var attr = idWithAttr.AsSpan(attributes + 1).ToString(); + if (TryGetAstMessage(id, out var astMessage)) + { + return astMessage.GetAttribute(attr) != null; + } + + return false; + } + + /// + /// Retrieves the attribute message by processing the given message template with the provided arguments. + /// + /// The string consisting of `messageId.Attribute`. + /// The dictionary of arguments to be used for resolution in the message template. Can be null. + /// The processed message. + /// Thrown when there are errors encountered during attribute substitution. + string? GetAttrMessage(string msgWithAttr, params (string, IFluentType)[] args) + { + var dictionary = new Dictionary(args.Length); + foreach (var (key, val) in args) + { + dictionary.Add(key, val); + } + + TryGetAttrMessage(msgWithAttr, dictionary, out var errors, out var message); + if (errors is { Count: > 0 }) + { + throw new LinguiniException(errors); + } + + return message; + } + + /// + /// Tries to retrieve an attribute message. + /// + /// The message with attribute. + /// The arguments passed with the message. + /// The list of errors that occurred during the message retrieval process. + /// The retrieved message. + /// True if the attribute message is found; otherwise, false. + bool TryGetAttrMessage(string msgWithAttr, IDictionary? args, + [NotNullWhen(false)] out IList? errors, [NotNullWhen(true)] out string? message) + { + if (msgWithAttr.Contains(".")) + { + var split = msgWithAttr.Split('.'); + return TryGetMessage(split[0], split[1], args, out errors, out message); + } + + return TryGetMessage(msgWithAttr, null, args, out errors, out message); + } + + /// + /// Tries to get a message based on the provided parameters. + /// + /// The identifier of the message. + /// The attribute of the message. + /// The arguments to format the message with. + /// The list of errors that occurred during the message retrieval process. + /// The retrieved message. + /// True if the message was successfully retrieved, otherwise false. + public bool TryGetMessage(string id, string? attribute, IDictionary? args, + [NotNullWhen(false)] out IList? errors, [NotNullWhen(true)] out string? message) + { + string? value = null; + errors = new List(); + + if (TryGetAstMessage(id, out var astMessage)) + { + var pattern = attribute != null + ? astMessage.GetAttribute(attribute)?.Value + : astMessage.Value; + + if (pattern == null) + { + var msg = attribute == null + ? id + : $"{id}.{attribute}"; + errors.Add(ResolverFluentError.NoValue($"{msg}")); + message = FluentNone.None.ToString(); + return false; + } + + value = FormatPattern(pattern, args, out errors); + } + + message = value; + return message != null; + } + } + + public static class ReadBundleExtensions + { + /// + /// Convenience method for + /// + /// The bundle to retrieve the message from. + /// The identifier with attribute. + /// True if the identifier with attribute has a message; otherwise, false. + public static bool HasAttrMessage(this IReadBundle bundle, string idWithAttr) + { + return bundle.HasAttrMessage(idWithAttr); + } + + /// + /// Convenience method for + /// + /// The bundle to retrieve the message from. + /// The message with attribute to retrieve. + /// Optional arguments to format the message. + /// The attribute message from the read bundle. + public static string? GetAttrMessage(this IReadBundle bundle, string msgWithAttr, + params (string, IFluentType)[] args) + { + return bundle.GetAttrMessage(msgWithAttr, args); + } + + /// + /// Convenience method for + /// + /// The bundle to retrieve the message from. + /// The message with attribute + /// Optional arguments to be passed to the attribute message. + /// When this method returns, contains any errors that occured during retrieval, if any. + /// When this method returns, contains the retrieved attribute message, if it exists. + /// true if the attribute message was successfully retrieved; otherwise, false. + public static bool TryGetAttrMessage(this IReadBundle bundle, string msgWithAttr, + IDictionary? args, + [NotNullWhen(false)] out IList? errors, [NotNullWhen(true)] out string? message) + { + return bundle.TryGetAttrMessage(msgWithAttr, args, out errors, out message); + } + + /// + /// Convenience method for + /// + /// The bundle to retrieve the message from. + /// The identifier of the message. + /// The attribute of the message (optional). + /// The arguments for the message (optional). + /// When this method returns false, contains a list of errors that occurred while trying to retrieve the message; otherwise, null. + /// When this method returns true, contains the retrieved message; otherwise, null. + /// True if the message was successfully retrieved, otherwise false. + public static bool TryGetMessage(this IReadBundle bundle, string id, string? attribute, + IDictionary? args, + [NotNullWhen(false)] out IList? errors, [NotNullWhen(true)] out string? message) + { + return bundle.TryGetMessage(id, attribute, args, out errors, out message); + } + + /// + /// Tries to get a message from the specified bundle. + /// + /// The bundle from which to retrieve the message. + /// The identifier of the message to retrieve. + /// Optional arguments used to format the message (optional). + /// When this method returns false, contains a list of errors that occurred while trying to retrieve the message; otherwise, null. + /// When this method returns true, contains the retrieved message; otherwise, null. + /// True if the message was successfully retrieved; otherwise, false. + public static bool TryGetMessage(this IReadBundle bundle, string id, + IDictionary? args, + [NotNullWhen(false)] out IList? errors, [NotNullWhen(true)] out string? message) + { + return bundle.TryGetMessage(id, null, args, out errors, out message); + } + } +} \ No newline at end of file diff --git a/Linguini.Bundle/InsertBehavior.cs b/Linguini.Bundle/InsertBehavior.cs deleted file mode 100644 index 8ec6bab..0000000 --- a/Linguini.Bundle/InsertBehavior.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Linguini.Bundle -{ - internal enum InsertBehavior : byte - { - None = 0, - OverwriteExisting = 1, - ThrowOnExisting = 2, - } -} \ No newline at end of file diff --git a/Linguini.Bundle/Linguini.Bundle.csproj b/Linguini.Bundle/Linguini.Bundle.csproj index 4ea1738..9915581 100644 --- a/Linguini.Bundle/Linguini.Bundle.csproj +++ b/Linguini.Bundle/Linguini.Bundle.csproj @@ -19,7 +19,7 @@ It provides easy to use and extend system for describing translations.https://github.com/Ygg01/Linguini git 0.7.0 - netstandard2.1;net6.0 + net8.0;netstandard2.1;net6.0 linguini.jpg README.md diff --git a/Linguini.Bundle/NonConcurrentBundle.cs b/Linguini.Bundle/NonConcurrentBundle.cs new file mode 100644 index 0000000..7f35070 --- /dev/null +++ b/Linguini.Bundle/NonConcurrentBundle.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Linguini.Bundle.Errors; +using Linguini.Bundle.Types; +using Linguini.Shared.Types.Bundle; +using Linguini.Syntax.Ast; + +// ReSharper disable UnusedType.Global + +namespace Linguini.Bundle +{ + public sealed class NonConcurrentBundle : FluentBundle, IEquatable + { + internal Dictionary Functions = new(); + private Dictionary _terms = new(); + private Dictionary _messages = new(); + + /// + protected override void AddMessageOverriding(AstMessage message) + { + _messages[message.GetId()] = message; + } + + /// + protected override void AddTermOverriding(AstTerm term) + { + _terms[term.GetId()] = term; + } + + /// + protected override bool TryAddTerm(AstTerm term, List? errors) + { + if (_terms.TryAdd(term.GetId(), term)) return true; + errors ??= new List(); + errors.Add(new OverrideFluentError(term.GetId(), EntryKind.Term)); + return false; + } + + /// + protected override bool TryAddMessage(AstMessage message, List? errors) + { + if (_messages.TryAdd(message.GetId(), message)) return true; + errors ??= new List(); + errors.Add(new OverrideFluentError(message.GetId(), EntryKind.Message)); + return false; + } + + + /// + public override bool TryAddFunction(string funcName, ExternalFunction fluentFunction) + { + return Functions.TryAdd(funcName, fluentFunction); + } + + /// + public override void AddFunctionOverriding(string funcName, ExternalFunction fluentFunction) + { + Functions[funcName] = fluentFunction; + } + + /// + public override void AddFunctionUnchecked(string funcName, ExternalFunction fluentFunction) + { + Functions.Add(funcName, fluentFunction); + } + + /// + public override bool HasMessage(string identifier) + { + return _messages.ContainsKey(identifier); + } + + /// + public override bool TryGetAstMessage(string ident, [NotNullWhen(true)] out AstMessage? message) + { + return _messages.TryGetValue(ident, out message); + } + + /// + public override bool TryGetAstTerm(string ident, [NotNullWhen(true)] out AstTerm? term) + { + return _terms.TryGetValue(ident, out term); + } + + + /// + public override bool TryGetFunction(string funcName, [NotNullWhen(true)] out FluentFunction? function) + { + return Functions.TryGetValue(funcName, out function); + } + + /// + public override IEnumerable GetMessageEnumerable() + { + return _messages.Keys.ToArray(); + } + + /// + public override IEnumerable GetFuncEnumerable() + { + return Functions.Keys.ToArray(); + } + + /// + public override IEnumerable GetTermEnumerable() + { + return _terms.Keys.ToArray(); + } + + internal override IDictionary GetMessagesDictionary() + { + return _messages; + } + + internal override IDictionary GetTermsDictionary() + { + return _terms; + } + + internal override IDictionary GetFunctionDictionary() + { + return Functions; + } + + + /// + public override FluentBundle DeepClone() + { + return new NonConcurrentBundle() + { + Functions = new Dictionary(Functions), + _terms = new Dictionary(_terms), + _messages = new Dictionary(_messages), + Culture = (CultureInfo)Culture.Clone(), + Locales = new List(Locales), + UseIsolating = UseIsolating, + TransformFunc = (Func?)TransformFunc?.Clone(), + FormatterFunc = (Func?)FormatterFunc?.Clone(), + MaxPlaceable = MaxPlaceable, + EnableExtensions = EnableExtensions, + }; + } + + public static NonConcurrentBundle Thaw(FrozenBundle frozenBundle) + { + return new NonConcurrentBundle + { + _messages = new Dictionary(frozenBundle.Messages), + Functions = new Dictionary(frozenBundle.Functions), + _terms = new Dictionary(frozenBundle.Terms), + FormatterFunc = frozenBundle.FormatterFunc, + Locales = frozenBundle.Locales, + UseIsolating = frozenBundle.UseIsolating, + MaxPlaceable = frozenBundle.MaxPlaceable, + EnableExtensions = frozenBundle.EnableExtensions, + TransformFunc = frozenBundle.TransformFunc, + Culture = frozenBundle.Culture + }; + } + + public bool Equals(NonConcurrentBundle? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return base.Equals(other) && Functions.SequenceEqual(other.Functions) && _terms.SequenceEqual(other._terms) && + _messages.SequenceEqual(other._messages); + } + + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is NonConcurrentBundle other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), Functions, _terms, _messages); + } + } +} \ No newline at end of file diff --git a/Linguini.Bundle/Resolver/ResolverHelpers.cs b/Linguini.Bundle/Resolver/ResolverHelpers.cs index f14f093..aa8eabc 100644 --- a/Linguini.Bundle/Resolver/ResolverHelpers.cs +++ b/Linguini.Bundle/Resolver/ResolverHelpers.cs @@ -21,7 +21,7 @@ public static IFluentType Resolve(this Pattern self, Scope scope) { if (self.Elements[0] is TextLiteral textLiteral) { - return GetFluentString(textLiteral.ToString(), scope.Bundle.TransformFunc); + return GetFluentString(textLiteral.ToString(), scope.TransformFunc); } } diff --git a/Linguini.Bundle/Resolver/Scope.cs b/Linguini.Bundle/Resolver/Scope.cs index 00c3784..b979730 100644 --- a/Linguini.Bundle/Resolver/Scope.cs +++ b/Linguini.Bundle/Resolver/Scope.cs @@ -1,7 +1,7 @@ -#nullable enable -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using Linguini.Bundle.Errors; using Linguini.Shared.Types; @@ -12,7 +12,9 @@ namespace Linguini.Bundle.Resolver { public class Scope : IScope { - public readonly FluentBundle Bundle; + public readonly IReadBundle Bundle; + private readonly CultureInfo _culture; + private readonly int _maxPlaceable; private readonly Dictionary? _args; private Dictionary? _localNameArgs; private List? _localPosArgs; @@ -24,6 +26,12 @@ public Scope(FluentBundle fluentBundle, IDictionary? args) { Placeable = 0; Bundle = fluentBundle; + _maxPlaceable = fluentBundle.MaxPlaceable; + _culture = fluentBundle.Culture; + TransformFunc = fluentBundle.TransformFunc; + FormatterFunc = fluentBundle.FormatterFunc; + UseIsolating = fluentBundle.UseIsolating; + FormatterFunc = fluentBundle.FormatterFunc; Dirty = false; _errors = new List(); @@ -35,6 +43,28 @@ public Scope(FluentBundle fluentBundle, IDictionary? args) _errors = new List(); } + + public Scope(FrozenBundle frozenBundle, IDictionary? args) + { + Placeable = 0; + Bundle = frozenBundle; + _maxPlaceable = frozenBundle.MaxPlaceable; + _culture = frozenBundle.Culture; + TransformFunc = frozenBundle.TransformFunc; + FormatterFunc = frozenBundle.FormatterFunc; + UseIsolating = frozenBundle.UseIsolating; + Dirty = false; + + _errors = new List(); + _travelled = new List(); + _args = args != null ? new Dictionary(args) : null; + + _localNameArgs = null; + _localPosArgs = null; + _errors = new List(); + } + + public bool Dirty { get; set; } private short Placeable { get; set; } @@ -44,10 +74,16 @@ public Scope(FluentBundle fluentBundle, IDictionary? args) public IReadOnlyList? LocalPosArgs => _localPosArgs; public IReadOnlyDictionary? Args => _args; + public Func? FormatterFunc { get; } + public Func? TransformFunc { get; } + + + public bool UseIsolating { get; init; } + public bool IncrPlaceable() { - return ++Placeable <= Bundle.MaxPlaceable; + return ++Placeable <= _maxPlaceable; } public void AddError(ResolverFluentError resolverFluentError) @@ -171,7 +207,7 @@ public void ClearLocalArgs() public PluralCategory GetPluralRules(RuleType type, FluentNumber number) { - return ResolverHelpers.PluralRules.GetPluralCategory(Bundle.Culture, type, number); + return ResolverHelpers.PluralRules.GetPluralCategory(_culture, type, number); } public string ResolveReference(Identifier refId) diff --git a/Linguini.Bundle/Resolver/WriterHelpers.cs b/Linguini.Bundle/Resolver/WriterHelpers.cs index 65fd330..7f7f18c 100644 --- a/Linguini.Bundle/Resolver/WriterHelpers.cs +++ b/Linguini.Bundle/Resolver/WriterHelpers.cs @@ -14,7 +14,7 @@ public static class WriterHelpers public static void Write(this Pattern pattern, TextWriter writer, Scope scope) { var len = pattern.Elements.Count; - var transformFunc = scope.Bundle.TransformFunc; + var transformFunc = scope.TransformFunc; var placeablePos = 0; for (var i = 0; i < len; i++) { @@ -46,7 +46,7 @@ public static void Write(this Pattern pattern, TextWriter writer, Scope scope) return; } - var needsIsolating = scope.Bundle.UseIsolating + var needsIsolating = scope.UseIsolating && len > 1; if (needsIsolating) @@ -215,9 +215,9 @@ private static void Write(this IInlineExpression self, TextWriter writer, Scope private static void Write(this IFluentType self, TextWriter writer, Scope scope) { - if (scope.Bundle.FormatterFunc != null) + if (scope.FormatterFunc != null) { - writer.Write(scope.Bundle.FormatterFunc(self)); + writer.Write(scope.FormatterFunc(self)); } writer.Write(self.AsString()); diff --git a/Linguini.Serialization/Linguini.Serialization.csproj b/Linguini.Serialization/Linguini.Serialization.csproj index 8106097..de7f55a 100644 --- a/Linguini.Serialization/Linguini.Serialization.csproj +++ b/Linguini.Serialization/Linguini.Serialization.csproj @@ -2,11 +2,11 @@ 0.7.0 - net6.0 linguini.jpg README.md serialization, linguini, utility Optional serialization library for Linguini + net6.0;net8.0 diff --git a/Linguini.Shared/Linguini.Shared.csproj b/Linguini.Shared/Linguini.Shared.csproj index 1c3eab8..6d525a3 100644 --- a/Linguini.Shared/Linguini.Shared.csproj +++ b/Linguini.Shared/Linguini.Shared.csproj @@ -10,7 +10,7 @@ fluent, i18n, internationalization, l10n, l20n, globalization, translation false 0.7.0 - netstandard2.1;net6.0 + net6.0;netstandard2.1;net8.0 linguini.jpg README.md diff --git a/Linguini.Syntax.Tests/Linguini.Syntax.Tests.csproj b/Linguini.Syntax.Tests/Linguini.Syntax.Tests.csproj index 12dbeb5..3599ec3 100644 --- a/Linguini.Syntax.Tests/Linguini.Syntax.Tests.csproj +++ b/Linguini.Syntax.Tests/Linguini.Syntax.Tests.csproj @@ -4,8 +4,8 @@ false enable Library - net6.0 linguini.jpg + net6.0;net8.0 diff --git a/Linguini.Syntax/Linguini.Syntax.csproj b/Linguini.Syntax/Linguini.Syntax.csproj index 8cd0005..27903c9 100644 --- a/Linguini.Syntax/Linguini.Syntax.csproj +++ b/Linguini.Syntax/Linguini.Syntax.csproj @@ -11,7 +11,7 @@ fluent, i18n, internationalization, l10n, l20n, globalization, translation https://github.com/Ygg01/Linguini git - netstandard2.1;net6.0 + net6.0;netstandard2.1;net8.0 0.7.0 README.md linguini.jpg diff --git a/PluralRules.Test/Cldr/CldrParserTest.cs b/PluralRules.Test/Cldr/CldrParserTest.cs index ed44313..3b3c28b 100644 --- a/PluralRules.Test/Cldr/CldrParserTest.cs +++ b/PluralRules.Test/Cldr/CldrParserTest.cs @@ -1,4 +1,3 @@ -#nullable enable using System; using NUnit.Framework; using PluralRules.Generator.Cldr; diff --git a/PluralRules.Test/PluralRules.Test.csproj b/PluralRules.Test/PluralRules.Test.csproj index e8f2a57..5499de4 100644 --- a/PluralRules.Test/PluralRules.Test.csproj +++ b/PluralRules.Test/PluralRules.Test.csproj @@ -5,13 +5,13 @@ enable PluralRules.Test Library - netstandard2.1 + net6.0 - - - + + + diff --git a/global.json b/global.json index cfd2c16..b93fcd8 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "rollForward": "feature", - "version": "7.0.401" + "version": "8.0.100" } } \ No newline at end of file