< Summary

Information
Class: TeleFlow.Telegram.I18n.Fluent.Internal.FluentCatalog
Assembly: TeleFlow.Framework.I18n.Fluent
File(s): /_/src/TeleFlow.Framework.I18n.Fluent/Internal/FluentCatalog.cs
Line coverage
83%
Covered lines: 82
Uncovered lines: 16
Coverable lines: 98
Total lines: 224
Line coverage: 83.6%
Branch coverage
77%
Covered branches: 31
Total branches: 40
Branch coverage: 77.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Initialize()75%4487.5%
Resolve(...)83.33%66100%
LoadCatalog()66.66%171268%
LoadLocale(...)83.33%131282.35%
CreateBundle(...)100%11100%
CreateResourceException(...)100%11100%
ResolveResourceRoot(...)100%22100%
Get(...)75%4485.71%

File(s)

/_/src/TeleFlow.Framework.I18n.Fluent/Internal/FluentCatalog.cs

#LineLine coverage
 1using System.Collections.Frozen;
 2using System.Globalization;
 3using Linguini.Bundle;
 4using Linguini.Bundle.Builder;
 5using Linguini.Bundle.Errors;
 6using TeleFlow.Framework.Application;
 7
 8namespace TeleFlow.Telegram.I18n.Fluent.Internal;
 9
 10/// <summary>
 11/// Owns the immutable in-memory Fluent catalog loaded from application resources during runtime validation.
 12/// Formatting performs exact, parent, and fallback selection without file access or resource parsing.
 13/// </summary>
 14internal sealed class FluentCatalog(TelegramFluentI18nOptions options)
 15{
 2416    private readonly object _initializationLock = new();
 17    private FrozenDictionary<string, LocaleBundles>? _bundles;
 18
 19    public void Initialize()
 20    {
 2621        if (_bundles is not null)
 22        {
 223            return;
 24        }
 25
 2426        lock (_initializationLock)
 27        {
 2428            if (_bundles is not null)
 29            {
 030                return;
 31            }
 32
 2433            _bundles = LoadCatalog();
 2234        }
 2235    }
 36
 37    public ResolvedFluentBundle Resolve(Locale requestedLocale, FluentRenderingMode mode)
 38    {
 81239        ArgumentNullException.ThrowIfNull(requestedLocale);
 40
 81241        var bundles = _bundles ?? throw new InvalidOperationException(
 81242            "The Fluent catalog has not been initialized. Run TeleFlow runtime validation before formatting messages.");
 43
 81244        for (var culture = requestedLocale.Culture;
 81545             !string.IsNullOrEmpty(culture.Name);
 346             culture = culture.Parent)
 47        {
 81448            if (bundles.TryGetValue(culture.Name, out var localeBundles))
 49            {
 81150                return new ResolvedFluentBundle(localeBundles.Locale, localeBundles.Get(mode));
 51            }
 52        }
 53
 154        var fallback = bundles[options.FallbackLocale.Name];
 155        return new ResolvedFluentBundle(fallback.Locale, fallback.Get(mode));
 56    }
 57
 58    private FrozenDictionary<string, LocaleBundles> LoadCatalog()
 59    {
 2460        var rootPath = ResolveResourceRoot(options.ResourcesPath);
 61
 2462        if (!Directory.Exists(rootPath))
 63        {
 064            throw new TeleFlowConfigurationException(
 065                $"Fluent resource directory '{rootPath}' does not exist.");
 66        }
 67
 2468        var catalog = new Dictionary<string, LocaleBundles>(StringComparer.OrdinalIgnoreCase);
 2469        var localeDirectories = Directory
 2470            .EnumerateDirectories(rootPath)
 2471            .Order(StringComparer.Ordinal)
 2472            .ToArray();
 73
 2474        if (localeDirectories.Length == 0)
 75        {
 076            throw new TeleFlowConfigurationException(
 077                $"Fluent resource directory '{rootPath}' does not contain locale directories.");
 78        }
 79
 10180        foreach (var localeDirectory in localeDirectories)
 81        {
 2782            var directoryName = Path.GetFileName(localeDirectory);
 83
 2784            if (!Locale.TryCreate(directoryName, out var locale))
 85            {
 086                throw new TeleFlowConfigurationException(
 087                    $"Fluent locale directory '{directoryName}' is not a valid locale name.");
 88            }
 89
 2790            if (catalog.ContainsKey(locale.Name))
 91            {
 092                throw new TeleFlowConfigurationException(
 093                    $"Fluent locale '{locale.Name}' is configured by more than one directory.");
 94            }
 95
 2796            catalog.Add(locale.Name, LoadLocale(locale, localeDirectory));
 97        }
 98
 2399        if (!catalog.ContainsKey(options.FallbackLocale.Name))
 100        {
 1101            throw new TeleFlowConfigurationException(
 1102                $"Fluent fallback locale '{options.FallbackLocale.Name}' does not have a resource catalog.");
 103        }
 104
 22105        return catalog.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
 106    }
 107
 108    private static LocaleBundles LoadLocale(Locale locale, string localeDirectory)
 109    {
 27110        var resourcePaths = Directory
 27111            .EnumerateFiles(localeDirectory, "*.ftl", SearchOption.AllDirectories)
 27112            .Order(StringComparer.Ordinal)
 27113            .ToArray();
 114
 27115        if (resourcePaths.Length == 0)
 116        {
 0117            throw new TeleFlowConfigurationException(
 0118                $"Fluent locale '{locale.Name}' does not contain any .ftl resources.");
 119        }
 120
 27121        var readers = new (TextReader Reader, string? FileName)[resourcePaths.Length];
 122
 123        try
 124        {
 110125            for (var index = 0; index < resourcePaths.Length; index++)
 126            {
 28127                readers[index] = (File.OpenText(resourcePaths[index]), resourcePaths[index]);
 128            }
 129
 27130            var ready = LinguiniBuilder
 27131                .Builder()
 27132                .CultureInfo(locale.Culture)
 28133                .AddResources(readers.Select(static item => (item.Reader, item.FileName)))
 27134                .SetUseIsolating(false)
 27135                .UseConcurrent();
 27136            var (baseBundle, errors) = ready.Build();
 137
 27138            if (errors is { Count: > 0 })
 139            {
 1140                throw CreateResourceException(locale, resourcePaths, errors);
 141            }
 142
 26143            return new LocaleBundles(
 26144                locale,
 26145                CreateBundle(baseBundle, locale.Culture, FluentRenderingMode.Plain),
 26146                CreateBundle(baseBundle, locale.Culture, FluentRenderingMode.Html),
 26147                CreateBundle(baseBundle, locale.Culture, FluentRenderingMode.MarkdownV2));
 148        }
 1149        catch (TeleFlowConfigurationException)
 150        {
 1151            throw;
 152        }
 0153        catch (Exception exception)
 154        {
 0155            throw new TeleFlowConfigurationException(
 0156                $"Fluent resources for locale '{locale.Name}' could not be loaded.",
 0157                exception);
 158        }
 159        finally
 160        {
 110161            foreach (var (reader, _) in readers)
 162            {
 28163                reader?.Dispose();
 164            }
 27165        }
 26166    }
 167
 168    private static FrozenBundle CreateBundle(
 169        FluentBundle baseBundle,
 170        CultureInfo culture,
 171        FluentRenderingMode mode)
 172    {
 78173        var bundle = baseBundle.DeepClone();
 78174        bundle.AddFunctionUnchecked("NUMBER", FluentFunctions.CreateNumber(culture, mode));
 78175        bundle.AddFunctionUnchecked("DATETIME", FluentFunctions.CreateDateTime(culture, mode));
 78176        return bundle.ToFrozenBundle();
 177    }
 178
 179    private static TeleFlowConfigurationException CreateResourceException(
 180        Locale locale,
 181        IEnumerable<string> resourcePaths,
 182        IEnumerable<FluentError> errors)
 183    {
 1184        return new TeleFlowConfigurationException(
 1185            $"Fluent resources for locale '{locale.Name}' are invalid:" +
 1186            Environment.NewLine +
 1187            string.Join(Environment.NewLine, resourcePaths.Select(static path => $"Resource: {path}")) +
 1188            Environment.NewLine +
 2189            string.Join(Environment.NewLine, errors.Select(static error => error.ToString())));
 190    }
 191
 192    private static string ResolveResourceRoot(string configuredPath)
 193    {
 24194        return Path.IsPathRooted(configuredPath)
 24195            ? Path.GetFullPath(configuredPath)
 24196            : Path.GetFullPath(configuredPath, AppContext.BaseDirectory);
 197    }
 198
 199    /// <summary>
 200    /// Groups the immutable plain-text and Telegram-markup bundles built from one parsed locale catalog.
 201    /// </summary>
 202    internal sealed record LocaleBundles(
 203        Locale Locale,
 204        FrozenBundle Plain,
 205        FrozenBundle Html,
 206        FrozenBundle MarkdownV2)
 207    {
 208        public FrozenBundle Get(FluentRenderingMode mode)
 209        {
 812210            return mode switch
 812211            {
 805212                FluentRenderingMode.Plain => Plain,
 5213                FluentRenderingMode.Html => Html,
 2214                FluentRenderingMode.MarkdownV2 => MarkdownV2,
 0215                _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown Fluent rendering mode.")
 812216            };
 217        }
 218    }
 219}
 220
 221/// <summary>
 222/// Couples the concrete locale selected by catalog fallback with its immutable mode-specific Linguini bundle.
 223/// </summary>
 224internal readonly record struct ResolvedFluentBundle(Locale Locale, FrozenBundle Bundle);