foxtail-runtime 0.5.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,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Foxtail
6
+ class Bundle
7
+ # Pattern resolution engine
8
+ # Corresponds to fluent-bundle/src/resolver.ts
9
+ class Resolver
10
+ # Unicode bidi isolation characters
11
+ FSI = "\u2068" # First Strong Isolate
12
+ private_constant :FSI
13
+
14
+ PDI = "\u2069" # Pop Directional Isolate
15
+ private_constant :PDI
16
+
17
+ def initialize(bundle) = @bundle = bundle
18
+
19
+ # Resolve a pattern with the given scope
20
+ def resolve_pattern(pattern, scope)
21
+ case pattern
22
+ when String
23
+ pattern
24
+ when Array
25
+ resolve_complex_pattern(pattern, scope)
26
+ else
27
+ raise ArgumentError, "Unexpected pattern type: #{pattern.class}"
28
+ end
29
+ end
30
+
31
+ # Resolve a complex pattern (array of elements)
32
+ def resolve_complex_pattern(elements, scope)
33
+ # Apply bidi isolation only when use_isolating is true and pattern has multiple elements
34
+ use_isolating = @bundle.use_isolating? && elements.size > 1
35
+
36
+ elements.map {|element| resolve_pattern_element(element, scope, use_isolating:) }.join
37
+ end
38
+
39
+ # Resolve individual pattern elements
40
+ def resolve_pattern_element(element, scope, use_isolating: false)
41
+ case element
42
+ when String
43
+ # Text elements are not wrapped with isolation marks
44
+ element
45
+ when Parser::AST::NumberLiteral
46
+ result = resolve_expression(element, scope)
47
+ # For numeric values in patterns, format for display
48
+ formatted = element.precision > 0 ? format_number(result, element.precision) : result.to_s
49
+ wrap_with_isolation(formatted, use_isolating)
50
+ when Parser::AST::VariableReference
51
+ # Apply implicit NUMBER/DATETIME formatting for variables at display time
52
+ result = resolve_expression(element, scope)
53
+ formatted = apply_implicit_function(result)
54
+ wrap_with_isolation(format_value(formatted), use_isolating)
55
+ when Parser::AST::FunctionReference
56
+ # Function results are Function::Value objects that need formatting
57
+ result = resolve_expression(element, scope)
58
+ wrap_with_isolation(format_value(result), use_isolating)
59
+ when Parser::AST::StringLiteral, Parser::AST::TermReference,
60
+ Parser::AST::MessageReference, Parser::AST::SelectExpression
61
+
62
+ result = resolve_expression(element, scope)
63
+ wrap_with_isolation(result.to_s, use_isolating)
64
+ else
65
+ wrap_with_isolation(element.to_s, use_isolating)
66
+ end
67
+ end
68
+
69
+ # Resolve expressions (variables, terms, messages, functions, etc.)
70
+ def resolve_expression(expr, scope)
71
+ case expr
72
+ when Parser::AST::StringLiteral
73
+ expr.value.to_s
74
+ when Parser::AST::NumberLiteral
75
+ # Return raw numeric value, not formatted string
76
+ expr.value
77
+ when Parser::AST::VariableReference
78
+ resolve_variable_reference(expr, scope)
79
+ when Parser::AST::TermReference
80
+ resolve_term_reference(expr, scope)
81
+ when Parser::AST::MessageReference
82
+ resolve_message_reference(expr, scope)
83
+ when Parser::AST::FunctionReference
84
+ resolve_function_call(expr, scope)
85
+ when Parser::AST::SelectExpression
86
+ resolve_select_expression(expr, scope)
87
+ else
88
+ scope.add_error("Unknown expression type: #{expr.class}")
89
+ "{#{expr.class}}"
90
+ end
91
+ end
92
+
93
+ private def format_number(value, precision=nil)
94
+ if precision && precision > 0
95
+ # Format with specified precision
96
+ "%.#{precision}f" % value
97
+ elsif value != Integer(value)
98
+ # Float without precision - keep as is
99
+ value.to_s
100
+ else
101
+ # Integer - format without decimal point
102
+ Integer(value).to_s
103
+ end
104
+ end
105
+
106
+ # Format a value for display
107
+ # Function::Value objects are formatted with the bundle
108
+ private def format_value(value)
109
+ case value
110
+ when Function::Value
111
+ value.format(bundle: @bundle)
112
+ else
113
+ value.to_s
114
+ end
115
+ end
116
+
117
+ private def wrap_with_isolation(value, use_isolating)
118
+ use_isolating ? "#{FSI}#{value}#{PDI}" : value
119
+ end
120
+
121
+ # Resolve variable references
122
+ # Returns raw value; implicit formatting is applied at display time
123
+ private def resolve_variable_reference(expr, scope)
124
+ name = expr.name
125
+
126
+ value = scope.variable(name)
127
+
128
+ if value.nil?
129
+ scope.add_error("Unknown variable: $#{name}")
130
+ "{$#{name}}"
131
+ else
132
+ value
133
+ end
134
+ end
135
+
136
+ # Apply implicit function based on value type
137
+ # Returns formatted string for numeric/time types, raw value otherwise
138
+ private def apply_implicit_function(value)
139
+ if implicit_number_type?(value)
140
+ apply_implicit_number(value)
141
+ elsif implicit_datetime_type?(value)
142
+ apply_implicit_datetime(value)
143
+ else
144
+ value
145
+ end
146
+ end
147
+
148
+ # Check if value should have NUMBER implicitly applied
149
+ private def implicit_number_type?(value) = value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(BigDecimal)
150
+
151
+ # Check if value should have DATETIME implicitly applied
152
+ private def implicit_datetime_type?(value) = value.is_a?(Time) || value.respond_to?(:to_time)
153
+
154
+ # Apply NUMBER function implicitly
155
+ private def apply_implicit_number(value)
156
+ func = @bundle.functions["NUMBER"]
157
+ return value unless func
158
+
159
+ func.call(value)
160
+ end
161
+
162
+ # Apply DATETIME function implicitly
163
+ private def apply_implicit_datetime(value)
164
+ func = @bundle.functions["DATETIME"]
165
+ return value unless func
166
+
167
+ func.call(value)
168
+ end
169
+
170
+ # Resolve term references
171
+ private def resolve_term_reference(expr, scope)
172
+ name = expr.name
173
+ attr = expr.attr
174
+
175
+ # Circular reference check
176
+ unless scope.track(name)
177
+ return "{-#{name}}"
178
+ end
179
+
180
+ term = @bundle.term("-#{name}")
181
+
182
+ if term.nil?
183
+ scope.add_error("Unknown term: -#{name}")
184
+ scope.release(name)
185
+ return "{-#{name}}"
186
+ end
187
+
188
+ # Resolve term value
189
+ if attr
190
+ result = resolve_term_attribute(term, attr, scope)
191
+ elsif term.value
192
+ result = resolve_pattern(term.value, scope)
193
+ else
194
+ scope.add_error("No value: -#{name}")
195
+ result = "{-#{name}}"
196
+ end
197
+
198
+ scope.release(name)
199
+ result
200
+ end
201
+
202
+ # Resolve message references
203
+ private def resolve_message_reference(expr, scope)
204
+ name = expr.name
205
+ attr = expr.attr
206
+
207
+ # Circular reference check
208
+ unless scope.track(name)
209
+ return "{#{name}}"
210
+ end
211
+
212
+ message = @bundle.message(name)
213
+
214
+ if message.nil?
215
+ scope.add_error("Unknown message: #{name}")
216
+ scope.release(name)
217
+ return "{#{name}}"
218
+ end
219
+
220
+ # Resolve message value
221
+ if attr
222
+ result = resolve_message_attribute(message, attr, scope)
223
+ elsif message.value
224
+ result = resolve_pattern(message.value, scope)
225
+ else
226
+ scope.add_error("No value: #{name}")
227
+ result = "{#{name}}"
228
+ end
229
+
230
+ scope.release(name)
231
+ result
232
+ end
233
+
234
+ # Resolve function calls
235
+ private def resolve_function_call(expr, scope)
236
+ func_name = expr.name
237
+ args = expr.args || []
238
+
239
+ func = @bundle.functions[func_name]
240
+
241
+ if func.nil?
242
+ scope.add_error("Unknown function: #{func_name}")
243
+ return "{#{func_name}()}"
244
+ end
245
+
246
+ # Check if any arguments failed to resolve (contain error markers)
247
+ initial_error_count = scope.errors.length
248
+
249
+ # Process arguments: first arg is positional, rest are named args (narg)
250
+ positional_args = []
251
+ options = {}
252
+
253
+ args.each do |arg|
254
+ case arg
255
+ when Parser::AST::NamedArgument
256
+ # Named argument - add to options hash
257
+ key = arg.name.to_sym
258
+ value = resolve_expression(arg.value, scope)
259
+ options[key] = value
260
+ else
261
+ # Positional argument
262
+ positional_args << resolve_expression(arg, scope)
263
+ end
264
+ end
265
+
266
+ # If arguments resolution generated errors, fail the entire function call
267
+ if scope.errors.length > initial_error_count
268
+ arg_list = args.map {|arg|
269
+ case arg
270
+ when Parser::AST::VariableReference then "$#{arg.name}"
271
+ when Parser::AST::NamedArgument then "#{arg.name}: #{arg.value}"
272
+ else arg.to_s
273
+ end
274
+ }.join(", ")
275
+ return "{#{func_name}(#{arg_list})}"
276
+ end
277
+
278
+ begin
279
+ # Create child scope for function execution
280
+ scope.child_scope
281
+
282
+ # Normalize arguments to Function::Value
283
+ normalized_args = positional_args.map {|arg| wrap_as_value(arg) }
284
+ normalized_opts = options.transform_values {|v| wrap_as_value(v) }
285
+
286
+ func.call(*normalized_args, **normalized_opts)
287
+ rescue => e
288
+ scope.add_error("Function error in #{func_name}: #{e.message}")
289
+ "{#{func_name}()}"
290
+ end
291
+ end
292
+
293
+ # Resolve select expressions
294
+ private def resolve_select_expression(expr, scope)
295
+ selector = expr.selector
296
+ variants = expr.variants
297
+ star_index = expr.star || 0
298
+
299
+ # Resolve selector value
300
+ selector_value = resolve_expression(selector, scope)
301
+
302
+ # Find matching variant
303
+ selected_variant = find_matching_variant(variants, selector_value, scope)
304
+
305
+ # Use default variant if no match
306
+ if selected_variant.nil? && star_index && star_index < variants.length
307
+ selected_variant = variants[star_index]
308
+ end
309
+
310
+ if selected_variant
311
+ resolve_pattern(selected_variant.value, scope)
312
+ else
313
+ scope.add_error("No variant found for selector: #{selector_value}")
314
+ selector_value.to_s
315
+ end
316
+ end
317
+
318
+ # Find a matching variant for the selector value
319
+ private def find_matching_variant(variants, selector_value, scope)
320
+ variants.find do |variant|
321
+ key = variant.key
322
+ key_value = resolve_expression(key, scope)
323
+
324
+ case key
325
+ when Parser::AST::NumberLiteral
326
+ # Numeric comparison
327
+ # If precision is 0, compare as integers
328
+ # Otherwise compare as floats
329
+ actual_selector = selector_value.is_a?(Function::Number) ? selector_value.value : selector_value
330
+ if actual_selector.is_a?(Numeric) && key_value.is_a?(Numeric)
331
+ if key.precision == 0
332
+ # Integer comparison when precision is 0
333
+ Integer(key_value) == Integer(actual_selector)
334
+ else
335
+ # Float comparison when precision > 0
336
+ key_value == actual_selector
337
+ end
338
+ else
339
+ # Fallback to string comparison if not both numeric
340
+ key_value.to_s == actual_selector.to_s
341
+ end
342
+ when Parser::AST::StringLiteral
343
+ # String comparison - check for ICU plural category match
344
+ if numeric_selector?(selector_value)
345
+ # Try ICU plural rules matching first
346
+ plural_category_match?(key_value, selector_value, scope) ||
347
+ # Fall back to direct string comparison
348
+ key_value.to_s == selector_value.to_s
349
+ else
350
+ # Direct string comparison for non-numeric selectors
351
+ key_value.to_s == selector_value.to_s
352
+ end
353
+ else
354
+ # General comparison
355
+ key_value == selector_value
356
+ end
357
+ end
358
+ end
359
+
360
+ # Check if selector value is numeric for plural rules processing
361
+ private def numeric_selector?(value)
362
+ value.is_a?(Numeric) ||
363
+ value.is_a?(Function::Number) ||
364
+ (value.is_a?(String) && value.match?(/^\d+(\.\d+)?$/))
365
+ end
366
+
367
+ # Check if key matches selector via ICU plural rules
368
+ private def plural_category_match?(key_str, selector_value, scope)
369
+ # Convert selector to numeric if needed
370
+ numeric_value =
371
+ case selector_value
372
+ when Numeric
373
+ selector_value
374
+ when Function::Number
375
+ selector_value.value
376
+ when String
377
+ if selector_value.match?(/^\d+$/)
378
+ Integer(selector_value)
379
+ elsif selector_value.match?(/^\d+\.\d+$/)
380
+ Float(selector_value)
381
+ else
382
+ return false
383
+ end
384
+ else
385
+ return false
386
+ end
387
+
388
+ # Use bundle's locale for plural rules
389
+ plural_rules = ICU4XCache.instance.plural_rules(@bundle.locale)
390
+ plural_category = plural_rules.select(numeric_value).to_s
391
+ key_str.to_s == plural_category
392
+ rescue => e
393
+ scope.add_error("Plural rule error: #{e.message}")
394
+ false
395
+ end
396
+
397
+ # Resolve term attributes
398
+ private def resolve_term_attribute(term, attr, scope)
399
+ attributes = term.attributes || {}
400
+ attr_pattern = attributes[attr]
401
+
402
+ if attr_pattern
403
+ resolve_pattern(attr_pattern, scope)
404
+ else
405
+ scope.add_error("Unknown term attribute: #{term.id}.#{attr}")
406
+ "{#{term.id}.#{attr}}"
407
+ end
408
+ end
409
+
410
+ # Resolve message attributes
411
+ private def resolve_message_attribute(message, attr, scope)
412
+ attributes = message.attributes || {}
413
+ attr_pattern = attributes[attr]
414
+
415
+ if attr_pattern
416
+ resolve_pattern(attr_pattern, scope)
417
+ else
418
+ scope.add_error("Unknown message attribute: #{message.id}.#{attr}")
419
+ "{#{message.id}.#{attr}}"
420
+ end
421
+ end
422
+
423
+ # Wrap a value as Function::Value for consistent function argument handling
424
+ # @param value [Object] the value to wrap
425
+ # @return [Function::Value] the wrapped value
426
+ private def wrap_as_value(value)
427
+ case value
428
+ when Function::Value
429
+ value
430
+ when Numeric
431
+ Function::Number[value, {}]
432
+ when Time
433
+ Function::DateTime[value, {}]
434
+ else
435
+ if value.respond_to?(:to_time)
436
+ Function::DateTime[value, {}]
437
+ else
438
+ Function::Value[value, {}]
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ class Bundle
5
+ # Variable scope and state management during resolution
6
+ # Corresponds to fluent-bundle/src/scope.ts
7
+ class Scope
8
+ attr_reader :bundle
9
+ attr_reader :args
10
+ attr_reader :locals
11
+ attr_reader :errors
12
+ attr_reader :dirty
13
+
14
+ def initialize(bundle, **args)
15
+ @bundle = bundle
16
+ @args = args # External variables passed to format()
17
+ @locals = {} # Local variables (set within functions)
18
+ @errors = [] # Error collection during resolution
19
+ @dirty = Set.new # Circular reference detection (message/term IDs)
20
+ end
21
+
22
+ # Get a variable value (checks locals first, then args)
23
+ def variable(name) = @locals[name.to_sym] || @args[name.to_sym]
24
+
25
+ # Set a local variable (used within functions)
26
+ def set_local(name, value) = @locals[name.to_sym] = value
27
+
28
+ # Track a message/term ID to detect circular references
29
+ def track(id)
30
+ if @dirty.include?(id)
31
+ add_error("Circular reference detected: #{id}")
32
+ return false
33
+ end
34
+
35
+ @dirty.add(id)
36
+ true
37
+ end
38
+
39
+ # Release tracking of a message/term ID
40
+ def release(id) = @dirty.delete(id)
41
+
42
+ # Add an error to the collection
43
+ def add_error(message) = @errors << message
44
+
45
+ # Check if an ID is currently being tracked (circular reference check)
46
+ def tracking?(id) = @dirty.include?(id)
47
+
48
+ # Create a child scope (for function calls)
49
+ def child_scope(**)
50
+ child = self.class.new(@bundle, **@args, **)
51
+ child.instance_variable_set(:@locals, @locals.dup)
52
+ child.instance_variable_set(:@dirty, @dirty.dup)
53
+ child
54
+ end
55
+
56
+ # Reset locals (used in some resolution contexts)
57
+ def clear_locals = @locals.clear
58
+
59
+ # Get all available variables (locals + args)
60
+ def all_variables = @args.merge(@locals)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ # Main runtime class for message formatting and localization.
5
+ #
6
+ # Bundle manages a collection of messages and terms for a single locale,
7
+ # providing formatting capabilities with support for pluralization,
8
+ # variable interpolation, and function calls.
9
+ #
10
+ # ICU4X handles locale fallback internally through the locale's parent chain
11
+ # (e.g., ja-JP → ja → und), so only a single locale is needed.
12
+ #
13
+ # @example Basic usage
14
+ # locale = ICU4X::Locale.parse("en-US")
15
+ # bundle = Foxtail::Bundle.new(locale)
16
+ #
17
+ # resource = Foxtail::Resource.from_string("hello = Hello, {$name}!")
18
+ # bundle.add_resource(resource)
19
+ #
20
+ # result = bundle.format("hello", name: "World")
21
+ # # => "Hello, World!"
22
+ #
23
+ # @example With custom functions (auto-merged with defaults)
24
+ # bundle = Foxtail::Bundle.new(locale, functions: {
25
+ # "UPPER" => ->(str, **_opts) { str.upcase }
26
+ # })
27
+ #
28
+ # Corresponds to fluent-bundle/src/bundle.ts in the original JavaScript implementation.
29
+ class Bundle
30
+ # @return [ICU4X::Locale] The locale for this bundle
31
+ attr_reader :locale
32
+ # @return [Hash{String => Bundle::Parser::AST::Message}] Message entries indexed by ID
33
+ attr_reader :messages
34
+ # @return [Hash{String => Bundle::Parser::AST::Term}] Term entries indexed by ID
35
+ attr_reader :terms
36
+ # @return [Hash{String => #call}] Custom formatting functions
37
+ attr_reader :functions
38
+ # @return [#call, nil] Optional message transformation function
39
+ attr_reader :transform
40
+
41
+ # Create a new Bundle instance.
42
+ #
43
+ # @param locale [ICU4X::Locale] The locale for this bundle
44
+ # @param functions [Hash{String => #call}] Custom formatting functions (defaults to NUMBER and DATETIME)
45
+ # @param use_isolating [Boolean] Whether to use Unicode bidi isolation marks for placeables (default: true)
46
+ # @param transform [#call, nil] Optional message transformation function (not currently implemented)
47
+ # @raise [ArgumentError] if locale is not an ICU4X::Locale instance
48
+ #
49
+ # @example Basic bundle creation
50
+ # locale = ICU4X::Locale.parse("en-US")
51
+ # bundle = Foxtail::Bundle.new(locale)
52
+ def initialize(locale, functions: {}, use_isolating: true, transform: nil)
53
+ raise ArgumentError, "locale must be an ICU4X::Locale instance, got: #{locale.class}" unless locale.is_a?(ICU4X::Locale)
54
+
55
+ @locale = locale
56
+ @messages = {} # id → Bundle::Parser::AST Message
57
+ @terms = {} # id → Bundle::Parser::AST Term
58
+ @functions = Function.defaults.merge(functions).freeze
59
+ @use_isolating = use_isolating
60
+ @transform = transform
61
+ end
62
+
63
+ # @return [Boolean] Whether to use Unicode bidi isolation marks
64
+ def use_isolating? = @use_isolating
65
+
66
+ # Add a resource to this bundle
67
+ #
68
+ # @param resource [Resource] The resource to add
69
+ # @param allow_overrides [Boolean] Whether to allow overriding existing messages/terms
70
+ # @return [self] Returns self for method chaining
71
+ def add_resource(resource, allow_overrides: false)
72
+ resource.entries.each do |entry|
73
+ # In fluent-bundle format, terms have '-' prefix in id
74
+ if entry.id&.start_with?("-")
75
+ add_term_entry(entry, allow_overrides)
76
+ else
77
+ add_message_entry(entry, allow_overrides)
78
+ end
79
+ end
80
+
81
+ self
82
+ end
83
+
84
+ # Check if a message exists
85
+ # @return [Boolean]
86
+ def message?(id) = @messages.key?(id.to_s)
87
+
88
+ # Get a message by ID
89
+ # @return [Bundle::Parser::AST::Message, nil]
90
+ def message(id) = @messages[id.to_s]
91
+
92
+ # Check if a term exists (private method in fluent-bundle)
93
+ # @return [Boolean]
94
+ def term?(id) = @terms.key?(id.to_s)
95
+
96
+ # Get a term by ID (private method in fluent-bundle)
97
+ # @return [Bundle::Parser::AST::Term, nil]
98
+ def term(id) = @terms[id.to_s]
99
+
100
+ # Format a message with the given arguments.
101
+ # Keyword arguments are substituted into the message as variables.
102
+ #
103
+ # @param id [String, Symbol] Message identifier to format
104
+ # @param errors [Array, nil] If provided, errors are collected into this array instead of being ignored.
105
+ # @return [String] Formatted message string, or the id itself if message not found
106
+ #
107
+ # @example Basic message formatting
108
+ # bundle.format("hello", name: "Alice")
109
+ # # => "Hello, Alice!" (assuming message: hello = Hello, {$name}!)
110
+ #
111
+ # @example Pluralization
112
+ # bundle.format("emails", count: 1)
113
+ # # => "You have one email." (assuming plural message)
114
+ #
115
+ # @example With error collection
116
+ # errors = []
117
+ # bundle.format("hello", errors, name: "Alice")
118
+ # # errors will contain any resolution errors
119
+ def format(id, errors=nil, **)
120
+ message = message(id)
121
+ return id.to_s unless message
122
+
123
+ format_pattern(message.value, errors, **)
124
+ end
125
+
126
+ # Format a pattern with the given arguments (using Resolver)
127
+ #
128
+ # @param pattern [String, Array] The pattern to format
129
+ # @param errors [Array, nil] If provided, errors are collected into this array
130
+ # @return [String] Formatted result
131
+ def format_pattern(pattern, errors=nil, **)
132
+ scope = Scope.new(self, **)
133
+ resolver = Resolver.new(self)
134
+ result = resolver.resolve_pattern(pattern, scope)
135
+
136
+ # Copy errors to provided array if given
137
+ errors&.concat(scope.errors)
138
+
139
+ result
140
+ end
141
+
142
+ private def add_message_entry(entry, allow_overrides)
143
+ id = entry.id
144
+ if @messages.key?(id) && !allow_overrides
145
+ # In full implementation, would add to errors
146
+ return
147
+ end
148
+
149
+ @messages[id] = entry
150
+ end
151
+
152
+ private def add_term_entry(entry, allow_overrides)
153
+ id = entry.id
154
+ if @terms.key?(id) && !allow_overrides
155
+ # In full implementation, would add to errors
156
+ return
157
+ end
158
+
159
+ @terms[id] = entry
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ # Base error class for all Foxtail-specific exceptions
5
+ class Error < StandardError; end
6
+ end