i18n-message_format 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module I18n
6
+ module MessageFormat
7
+ # An I18n backend that parses and formats ICU MessageFormat patterns.
8
+ #
9
+ # {Backend} implements +I18n::Backend::Base+ and can be used standalone or
10
+ # chained with other backends via +I18n::Backend::Chain+. Translation
11
+ # values that are Strings are treated as ICU MessageFormat patterns; any
12
+ # extra options passed to {#translate} (beyond +scope+, +default+, and
13
+ # +separator+) are forwarded as format arguments.
14
+ #
15
+ # Non-String values (e.g. arrays or hashes used as scopes) are returned
16
+ # as-is without formatting.
17
+ #
18
+ # @example Standalone usage
19
+ # backend = I18n::MessageFormat::Backend.new("config/locales/**/*.yml")
20
+ # I18n.backend = backend
21
+ # backend.load_translations
22
+ # I18n.t("greeting", name: "Alice") # => "Hello, Alice!"
23
+ #
24
+ # @example Chained with the default Simple backend
25
+ # I18n.backend = I18n::Backend::Chain.new(
26
+ # I18n::MessageFormat::Backend.new("config/locales/**/*.yml"),
27
+ # I18n.backend
28
+ # )
29
+ class Backend
30
+ include ::I18n::Backend::Base
31
+
32
+ # Creates a new backend that will load translations from the given
33
+ # file glob patterns.
34
+ #
35
+ # @param glob_patterns [Array<String>] one or more glob patterns passed
36
+ # to +Dir.glob+ to locate YAML translation files
37
+ def initialize(*glob_patterns)
38
+ @glob_patterns = glob_patterns
39
+ @translations = {}
40
+ @cache = Cache.new
41
+ end
42
+
43
+ # Loads translations from all YAML files matching the glob patterns
44
+ # provided at construction time.
45
+ #
46
+ # Files are expected to be YAML documents whose top-level keys are locale
47
+ # codes (e.g. +en+, +fr+) mapping to a hash of translation keys and
48
+ # values.
49
+ #
50
+ # @return [void]
51
+ def load_translations
52
+ @glob_patterns.each do |pattern|
53
+ Dir.glob(pattern).each do |file|
54
+ data = YAML.safe_load_file(file, permitted_classes: [Symbol])
55
+ data.each do |locale, translations|
56
+ store_translations(locale.to_sym, translations)
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # Merges +data+ into the in-memory translation store for +locale+.
63
+ #
64
+ # Nested hashes are flattened into dot-separated keys (e.g.
65
+ # +{ greeting: { hello: "Hi" } }+ becomes +{ :"greeting.hello" => "Hi" }+)
66
+ # before being merged.
67
+ #
68
+ # @param locale [Symbol, String] the locale to store translations for
69
+ # @param data [Hash] a (possibly nested) hash of translation keys/values
70
+ # @param options [Hash] currently unused; reserved for compatibility with
71
+ # +I18n::Backend::Base+
72
+ # @return [void]
73
+ def store_translations(locale, data, options = {})
74
+ @translations[locale] ||= {}
75
+ deep_merge!(@translations[locale], flatten_hash(data))
76
+ end
77
+
78
+ # Looks up and formats the translation identified by +key+ for +locale+.
79
+ #
80
+ # String values are interpreted as ICU MessageFormat patterns and
81
+ # formatted with any extra keys in +options+ as arguments. Non-string
82
+ # values are returned unchanged.
83
+ #
84
+ # Throws +:exception+ with an +I18n::MissingTranslation+ object when the
85
+ # key is not found, which is the conventional signal for +I18n::Backend::Base+
86
+ # to trigger the default/fallback mechanism.
87
+ #
88
+ # @param locale [Symbol, String] the locale to translate for
89
+ # @param key [Symbol, String] the translation key
90
+ # @param options [Hash] format arguments plus the standard I18n options
91
+ # (+:scope+, +:default+, +:separator+)
92
+ # @return [String, Object] the formatted translation string, or the raw
93
+ # value if it is not a String
94
+ # @raise [I18n::MissingTranslation] (via +throw+) when the key is absent
95
+ def translate(locale, key, options = {})
96
+ pattern = lookup(locale, key, options[:scope], options)
97
+
98
+ if pattern.nil?
99
+ throw(:exception, ::I18n::MissingTranslation.new(locale, key, options))
100
+ end
101
+
102
+ return pattern unless pattern.is_a?(String)
103
+
104
+ arguments = options.reject { |k, _| [:scope, :default, :separator].include?(k) }
105
+ nodes = @cache.fetch(pattern) { Parser.new(pattern).parse }
106
+ Formatter.new(nodes, arguments, locale).format
107
+ end
108
+
109
+ # Returns the list of locales for which translations have been stored.
110
+ #
111
+ # @return [Array<Symbol>]
112
+ def available_locales
113
+ @translations.keys
114
+ end
115
+
116
+ # Returns +true+ if any translations have been loaded into the backend.
117
+ #
118
+ # @return [Boolean]
119
+ def initialized?
120
+ !@translations.empty?
121
+ end
122
+
123
+ protected
124
+
125
+ # Looks up a translation value by locale and key.
126
+ #
127
+ # @param locale [Symbol, String] the locale to look up
128
+ # @param key [Symbol, String] the translation key
129
+ # @param scope [Array, Symbol, nil] optional scope prepended to the key
130
+ # @param options [Hash] may include +:separator+ to override the default
131
+ # key separator
132
+ # @return [Object, nil] the translation value, or +nil+ if not found
133
+ def lookup(locale, key, scope = [], options = {})
134
+ keys = ::I18n.normalize_keys(locale, key, scope, options[:separator])
135
+ keys.shift # remove locale
136
+
137
+ result = @translations[locale]
138
+ return nil unless result
139
+
140
+ keys.each do |k|
141
+ return nil unless result.is_a?(Hash)
142
+ result = result[k] || result[k.to_s]
143
+ return nil if result.nil?
144
+ end
145
+
146
+ result
147
+ end
148
+
149
+ private
150
+
151
+ def flatten_hash(hash, prefix = nil)
152
+ result = {}
153
+ hash.each do |key, value|
154
+ full_key = prefix ? :"#{prefix}.#{key}" : key.to_sym
155
+ if value.is_a?(Hash)
156
+ result.merge!(flatten_hash(value, full_key))
157
+ else
158
+ result[full_key] = value
159
+ end
160
+ end
161
+ result
162
+ end
163
+
164
+ def deep_merge!(base, override)
165
+ override.each do |key, value|
166
+ base[key] = value
167
+ end
168
+ base
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module MessageFormat
5
+ # A thread-safe, bounded LRU (Least Recently Used) cache.
6
+ #
7
+ # Used internally to memoize parsed ASTs so the same pattern string is
8
+ # only parsed once. The cache evicts the least recently accessed entry
9
+ # when the maximum size is reached.
10
+ class Cache
11
+ # Creates a new cache instance.
12
+ #
13
+ # @param max_size [Integer] maximum number of entries to retain before
14
+ # evicting the least recently used entry (default: +1000+)
15
+ def initialize(max_size: 1000)
16
+ @max_size = max_size
17
+ @data = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # Retrieves the value stored under +key+, updating its recency.
22
+ #
23
+ # @param key [Object] the cache key
24
+ # @return [Object, nil] the cached value, or +nil+ if not found
25
+ def get(key)
26
+ @mutex.synchronize do
27
+ return nil unless @data.key?(key)
28
+ value = @data.delete(key)
29
+ @data[key] = value
30
+ value
31
+ end
32
+ end
33
+
34
+ # Stores +value+ under +key+, evicting the LRU entry if necessary.
35
+ #
36
+ # @param key [Object] the cache key
37
+ # @param value [Object] the value to store
38
+ # @return [Object] the stored value
39
+ def set(key, value)
40
+ @mutex.synchronize do
41
+ @data.delete(key) if @data.key?(key)
42
+ @data[key] = value
43
+ evict if @data.size > @max_size
44
+ end
45
+ end
46
+
47
+ # Returns the cached value for +key+, computing and storing it on a miss.
48
+ #
49
+ # @param key [Object] the cache key
50
+ # @yield called on a cache miss to compute the value to store
51
+ # @yieldreturn [Object] the value to cache and return
52
+ # @return [Object] the cached or newly computed value
53
+ def fetch(key)
54
+ value = get(key)
55
+ return value unless value.nil? && !@mutex.synchronize { @data.key?(key) }
56
+ value = yield
57
+ set(key, value)
58
+ value
59
+ end
60
+
61
+ # Removes all entries from the cache.
62
+ #
63
+ # @return [void]
64
+ def clear
65
+ @mutex.synchronize { @data.clear }
66
+ end
67
+
68
+ private
69
+
70
+ def evict
71
+ @data.delete(@data.keys.first)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module MessageFormat
5
+ # Raised when a placeholder in the pattern has no corresponding argument.
6
+ class MissingArgumentError < Error
7
+ # The name of the missing argument as it appeared in the pattern.
8
+ #
9
+ # @return [String]
10
+ attr_reader :argument_name
11
+
12
+ # @param argument_name [String] the name of the missing argument
13
+ def initialize(argument_name)
14
+ @argument_name = argument_name
15
+ super("Missing argument: #{argument_name}")
16
+ end
17
+ end
18
+
19
+ # Walks an AST produced by {Parser} and renders a formatted string.
20
+ #
21
+ # Each node type is dispatched to a dedicated format method. Number, date,
22
+ # and time formatting delegate to +I18n.localize+. Plural and ordinal
23
+ # categorisation uses rules registered under +i18n.plural.rule+ /
24
+ # +i18n.ordinal.rule+ in the active I18n backend, falling back to simple
25
+ # one/other logic when no rule is present.
26
+ class Formatter
27
+ # Creates a new formatter.
28
+ #
29
+ # @param nodes [Array] the AST returned by {Parser#parse}
30
+ # @param arguments [Hash] argument values keyed by Symbol
31
+ # @param locale [Symbol, String] locale used for pluralisation and
32
+ # number/date/time formatting
33
+ def initialize(nodes, arguments, locale)
34
+ @nodes = nodes
35
+ @arguments = arguments
36
+ @locale = locale
37
+ end
38
+
39
+ # Renders the AST to a String.
40
+ #
41
+ # @return [String] the fully formatted message
42
+ # @raise [MissingArgumentError] if a required argument is absent from
43
+ # the arguments hash
44
+ # @raise [Error] if a plural or select branch cannot be resolved
45
+ def format
46
+ format_nodes(@nodes)
47
+ end
48
+
49
+ private
50
+
51
+ def format_nodes(nodes)
52
+ nodes.map { |node| format_node(node) }.join
53
+ end
54
+
55
+ def format_node(node)
56
+ case node
57
+ when Nodes::TextNode
58
+ node.value
59
+ when Nodes::ArgumentNode
60
+ fetch_argument(node.name).to_s
61
+ when Nodes::NumberFormatNode
62
+ format_number(node)
63
+ when Nodes::DateFormatNode
64
+ format_date(node)
65
+ when Nodes::TimeFormatNode
66
+ format_time(node)
67
+ when Nodes::PluralNode
68
+ format_plural(node)
69
+ when Nodes::SelectNode
70
+ format_select(node)
71
+ when Nodes::SelectOrdinalNode
72
+ format_select_ordinal(node)
73
+ else
74
+ raise Error, "Unknown node type: #{node.class}"
75
+ end
76
+ end
77
+
78
+ def fetch_argument(name)
79
+ key = name.to_sym
80
+ unless @arguments.key?(key)
81
+ raise MissingArgumentError.new(name)
82
+ end
83
+ @arguments[key]
84
+ end
85
+
86
+ def format_number(node)
87
+ value = fetch_argument(node.name)
88
+ ::I18n.localize(value, locale: @locale)
89
+ rescue ::I18n::MissingTranslationData
90
+ value.to_s
91
+ end
92
+
93
+ def format_date(node)
94
+ value = fetch_argument(node.name)
95
+ opts = { locale: @locale }
96
+ opts[:format] = node.style.to_sym if node.style
97
+ ::I18n.localize(value, **opts)
98
+ end
99
+
100
+ def format_time(node)
101
+ value = fetch_argument(node.name)
102
+ opts = { locale: @locale }
103
+ opts[:format] = node.style.to_sym if node.style
104
+ ::I18n.localize(value, **opts)
105
+ end
106
+
107
+ def format_plural(node)
108
+ value = fetch_argument(node.name)
109
+ effective_value = value - node.offset
110
+
111
+ # Check exact matches first
112
+ exact_key = :"=#{value}"
113
+ if node.branches.key?(exact_key)
114
+ return format_branch(node.branches[exact_key], effective_value)
115
+ end
116
+
117
+ # Use i18n pluralization rules
118
+ category = pluralize_cardinal(effective_value, @locale)
119
+ branch = node.branches[category] || node.branches[:other]
120
+ raise Error, "No matching plural branch for '#{category}'" unless branch
121
+
122
+ format_branch(branch, effective_value)
123
+ end
124
+
125
+ def format_select(node)
126
+ value = fetch_argument(node.name)
127
+ key = value.to_s.to_sym
128
+ branch = node.branches[key] || node.branches[:other]
129
+ raise Error, "No matching select branch for '#{key}'" unless branch
130
+
131
+ format_nodes(branch)
132
+ end
133
+
134
+ def format_select_ordinal(node)
135
+ value = fetch_argument(node.name)
136
+ effective_value = value - node.offset
137
+
138
+ exact_key = :"=#{value}"
139
+ if node.branches.key?(exact_key)
140
+ return format_branch(node.branches[exact_key], effective_value)
141
+ end
142
+
143
+ category = pluralize_ordinal(effective_value, @locale)
144
+ branch = node.branches[category] || node.branches[:other]
145
+ raise Error, "No matching selectordinal branch for '#{category}'" unless branch
146
+
147
+ format_branch(branch, effective_value)
148
+ end
149
+
150
+ def format_branch(nodes, numeric_value)
151
+ nodes.map do |node|
152
+ if node.is_a?(Nodes::TextNode)
153
+ node.value.gsub("#", numeric_value.to_s)
154
+ else
155
+ format_node(node)
156
+ end
157
+ end.join
158
+ end
159
+
160
+ def pluralize_cardinal(count, locale)
161
+ rule = ::I18n.t(:"i18n.plural.rule", locale: locale, default: nil, resolve: false)
162
+ if rule.respond_to?(:call)
163
+ rule.call(count)
164
+ else
165
+ count == 1 ? :one : :other
166
+ end
167
+ end
168
+
169
+ def pluralize_ordinal(count, locale)
170
+ rule = ::I18n.t(:"i18n.ordinal.rule", locale: locale, default: nil, resolve: false)
171
+ if rule.respond_to?(:call)
172
+ rule.call(count)
173
+ else
174
+ :other
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module MessageFormat
5
+ # Namespace for the AST node types produced by {Parser}.
6
+ #
7
+ # Each node is a +Struct+ whose members correspond to the semantic
8
+ # components of the ICU MessageFormat construct it represents.
9
+ module Nodes
10
+ # A literal text segment that requires no further processing.
11
+ #
12
+ # @!attribute [rw] value
13
+ # @return [String] the literal text content
14
+ TextNode = Struct.new(:value)
15
+
16
+ # A simple argument placeholder, e.g. +{name}+.
17
+ #
18
+ # @!attribute [rw] name
19
+ # @return [String] the argument name as it appears in the pattern
20
+ ArgumentNode = Struct.new(:name)
21
+
22
+ # A +{name, number}+ or +{name, number, style}+ argument.
23
+ #
24
+ # @!attribute [rw] name
25
+ # @return [String] the argument name
26
+ # @!attribute [rw] style
27
+ # @return [String, nil] optional number format style (e.g. +"integer"+)
28
+ NumberFormatNode = Struct.new(:name, :style)
29
+
30
+ # A +{name, date}+ or +{name, date, style}+ argument.
31
+ #
32
+ # @!attribute [rw] name
33
+ # @return [String] the argument name
34
+ # @!attribute [rw] style
35
+ # @return [String, nil] optional date format style (e.g. +"short"+)
36
+ DateFormatNode = Struct.new(:name, :style)
37
+
38
+ # A +{name, time}+ or +{name, time, style}+ argument.
39
+ #
40
+ # @!attribute [rw] name
41
+ # @return [String] the argument name
42
+ # @!attribute [rw] style
43
+ # @return [String, nil] optional time format style (e.g. +"short"+)
44
+ TimeFormatNode = Struct.new(:name, :style)
45
+
46
+ # A +{name, plural, ...}+ argument.
47
+ #
48
+ # @!attribute [rw] name
49
+ # @return [String] the argument name
50
+ # @!attribute [rw] branches
51
+ # @return [Hash{Symbol => Array<Nodes::TextNode, ...>}] plural branches
52
+ # keyed by plural category (e.g. +:one+, +:other+) or exact-value
53
+ # keys like +:=0+
54
+ # @!attribute [rw] offset
55
+ # @return [Integer] value subtracted from the argument before
56
+ # pluralisation (default: +0+)
57
+ PluralNode = Struct.new(:name, :branches, :offset) do
58
+ # @param name [String]
59
+ # @param branches [Hash]
60
+ # @param offset [Integer]
61
+ def initialize(name, branches, offset = 0)
62
+ super(name, branches, offset)
63
+ end
64
+ end
65
+
66
+ # A +{name, select, ...}+ argument.
67
+ #
68
+ # @!attribute [rw] name
69
+ # @return [String] the argument name
70
+ # @!attribute [rw] branches
71
+ # @return [Hash{Symbol => Array}] select branches keyed by selector
72
+ # value, with an optional +:other+ fallback
73
+ SelectNode = Struct.new(:name, :branches)
74
+
75
+ # A +{name, selectordinal, ...}+ argument.
76
+ #
77
+ # @!attribute [rw] name
78
+ # @return [String] the argument name
79
+ # @!attribute [rw] branches
80
+ # @return [Hash{Symbol => Array}] ordinal plural branches keyed by
81
+ # ordinal category (e.g. +:one+, +:two+, +:few+, +:other+) or
82
+ # exact-value keys like +:=1+
83
+ # @!attribute [rw] offset
84
+ # @return [Integer] value subtracted from the argument before
85
+ # categorisation (default: +0+)
86
+ SelectOrdinalNode = Struct.new(:name, :branches, :offset) do
87
+ # @param name [String]
88
+ # @param branches [Hash]
89
+ # @param offset [Integer]
90
+ def initialize(name, branches, offset = 0)
91
+ super(name, branches, offset)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n
4
+ module MessageFormat
5
+ # Provides CLDR ordinal plural rules for use with +selectordinal+ arguments.
6
+ #
7
+ # Ordinal rules map a number to a category symbol (+:one+, +:two+, +:few+,
8
+ # +:other+, etc.) according to the Unicode CLDR ordinal plural rules for a
9
+ # given locale.
10
+ #
11
+ # Rules are installed into the active I18n backend under the
12
+ # +i18n.ordinal.rule+ key, where {Formatter} looks for them at runtime.
13
+ #
14
+ # @example Installing rules for English
15
+ # I18n::MessageFormat::OrdinalRules.install(:en)
16
+ #
17
+ # @example Installing all bundled rules
18
+ # I18n::MessageFormat::OrdinalRules.install_all
19
+ module OrdinalRules
20
+ # Built-in CLDR ordinal plural rules keyed by locale symbol.
21
+ #
22
+ # Each value is a +Proc+ that accepts an integer +n+ and returns the
23
+ # appropriate plural category symbol.
24
+ #
25
+ # @return [Hash{Symbol => Proc}]
26
+ RULES = {
27
+ en: lambda { |n|
28
+ mod10 = n % 10
29
+ mod100 = n % 100
30
+ if mod10 == 1 && mod100 != 11
31
+ :one
32
+ elsif mod10 == 2 && mod100 != 12
33
+ :two
34
+ elsif mod10 == 3 && mod100 != 13
35
+ :few
36
+ else
37
+ :other
38
+ end
39
+ }
40
+ }.freeze
41
+
42
+ # Installs the ordinal plural rule for +locale+ into the active I18n
43
+ # backend.
44
+ #
45
+ # Does nothing if no rule is defined for the given locale.
46
+ #
47
+ # @param locale [Symbol, String] the locale to install (e.g. +:en+)
48
+ # @return [void]
49
+ def self.install(locale)
50
+ rule = RULES[locale.to_sym]
51
+ return unless rule
52
+
53
+ ::I18n.backend.store_translations(locale, { i18n: { ordinal: { rule: rule } } })
54
+ end
55
+
56
+ # Installs ordinal plural rules for every locale in {RULES}.
57
+ #
58
+ # @return [void]
59
+ def self.install_all
60
+ RULES.each_key { |locale| install(locale) }
61
+ end
62
+ end
63
+ end
64
+ end