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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/lib/foxtail/bundle/parser/ast.rb +166 -0
- data/lib/foxtail/bundle/parser.rb +543 -0
- data/lib/foxtail/bundle/resolver.rb +444 -0
- data/lib/foxtail/bundle/scope.rb +63 -0
- data/lib/foxtail/bundle.rb +162 -0
- data/lib/foxtail/error.rb +6 -0
- data/lib/foxtail/function/datetime.rb +39 -0
- data/lib/foxtail/function/number.rb +45 -0
- data/lib/foxtail/function/value.rb +26 -0
- data/lib/foxtail/function.rb +46 -0
- data/lib/foxtail/icu4x_cache.rb +57 -0
- data/lib/foxtail/resource.rb +81 -0
- data/lib/foxtail/runtime/version.rb +9 -0
- data/lib/foxtail/sequence.rb +49 -0
- data/lib/foxtail-runtime.rb +27 -0
- data/lib/foxtail.rb +3 -0
- metadata +125 -0
|
@@ -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
|