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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Function
5
+ # Wraps a datetime value with formatting options
6
+ # The raw value is preserved for selector matching
7
+ class DateTime < Value
8
+ # Convert FTL/JS style datetime options to ICU4X options
9
+ # @param options [Hash] FTL/JS style options (camelCase)
10
+ # @return [Hash] ICU4X style options (snake_case with symbols)
11
+ def self.convert_options(options)
12
+ result = {}
13
+
14
+ options.each do |key, value|
15
+ case key
16
+ when :dateStyle
17
+ result[:date_style] = value.to_sym
18
+ when :timeStyle
19
+ result[:time_style] = value.to_sym
20
+ when :timeZone
21
+ result[:time_zone] = value.to_s
22
+ else
23
+ warn "Unknown DATETIME option: #{key}"
24
+ end
25
+ end
26
+
27
+ result
28
+ end
29
+
30
+ # Format the datetime using ICU4X
31
+ # @param bundle [Foxtail::Bundle] The bundle providing locale and context
32
+ # @return [String] The formatted datetime
33
+ def format(bundle:)
34
+ icu_options = self.class.convert_options(options)
35
+ ICU4XCache.instance.datetime_formatter(bundle.locale, **icu_options).format(value)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Function
5
+ # Wraps a numeric value with formatting options
6
+ # The raw value is preserved for selector matching (plural rules)
7
+ class Number < Value
8
+ # Convert FTL/JS style number options to ICU4X options
9
+ # @param options [Hash] FTL/JS style options (camelCase)
10
+ # @return [Hash] ICU4X style options (snake_case with symbols)
11
+ def self.convert_options(options)
12
+ result = {}
13
+
14
+ options.each do |key, value|
15
+ case key
16
+ when :style
17
+ result[:style] = value.to_sym
18
+ when :currency
19
+ result[:currency] = value.to_s
20
+ when :minimumIntegerDigits
21
+ result[:minimum_integer_digits] = Integer(value)
22
+ when :minimumFractionDigits
23
+ result[:minimum_fraction_digits] = Integer(value)
24
+ when :maximumFractionDigits
25
+ result[:maximum_fraction_digits] = Integer(value)
26
+ when :useGrouping
27
+ result[:use_grouping] = value
28
+ else
29
+ warn "Unknown NUMBER option: #{key}"
30
+ end
31
+ end
32
+
33
+ result
34
+ end
35
+
36
+ # Format the number using ICU4X
37
+ # @param bundle [Foxtail::Bundle] The bundle providing locale and context
38
+ # @return [String] The formatted number
39
+ def format(bundle:)
40
+ icu_options = self.class.convert_options(options)
41
+ ICU4XCache.instance.number_formatter(bundle.locale, **icu_options).format(value)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Function
5
+ Value = Data.define(:value, :options)
6
+
7
+ # Base class for deferred-formatting values
8
+ # Wraps a value with formatting options, deferring locale-specific formatting until display time
9
+ #
10
+ # @!attribute [r] value
11
+ # @return [Object] The wrapped raw value
12
+ # @!attribute [r] options
13
+ # @return [Hash] Formatting options
14
+ class Value
15
+ # Format the value for display
16
+ # Subclasses may override for locale-specific formatting
17
+ # @param bundle [Foxtail::Bundle] The bundle providing locale and context (unused in base implementation)
18
+ # @return [String] The formatted value
19
+ def format(**) = value.to_s
20
+
21
+ # String representation for interpolation
22
+ # @return [String] The string representation of the wrapped value
23
+ def to_s = value.to_s
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ # Built-in formatting functions for FTL
5
+ # Uses ICU4X for number and datetime formatting
6
+ module Function
7
+ # Default functions available to all bundles
8
+ # @return [Hash{String => #call}] Function name to callable object mapping
9
+ def self.defaults
10
+ {
11
+ "NUMBER" => ->(value, **options) {
12
+ # Unwrap value and merge options from nested function calls (like fluent.js)
13
+ raw_value, existing_options = unwrap_value(value)
14
+ unwrapped_options = unwrap_options(options)
15
+ Number[raw_value, existing_options.merge(unwrapped_options)]
16
+ },
17
+ "DATETIME" => ->(value, **options) {
18
+ # Unwrap value and merge options from nested function calls (like fluent.js)
19
+ raw_value, existing_options = unwrap_value(value)
20
+ unwrapped_options = unwrap_options(options)
21
+ DateTime[raw_value, existing_options.merge(unwrapped_options)]
22
+ }
23
+ }
24
+ end
25
+
26
+ # Unwrap a Function::Value to get raw value and options
27
+ # @param value [Object] the value to unwrap
28
+ # @return [Array(Object, Hash)] the raw value and options
29
+ def self.unwrap_value(value)
30
+ if value.is_a?(Value)
31
+ [value.value, value.options]
32
+ else
33
+ [value, {}]
34
+ end
35
+ end
36
+
37
+ # Unwrap option values that may be Function::Value instances
38
+ # @param options [Hash] the options hash
39
+ # @return [Hash] options with unwrapped values
40
+ def self.unwrap_options(options)
41
+ options.transform_values do |v|
42
+ v.is_a?(Value) ? v.value : v
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/cache"
4
+ require "singleton"
5
+
6
+ module Foxtail
7
+ # Singleton cache for ICU4X formatter and rules instances.
8
+ #
9
+ # ICU4X formatters and rules internally load and parse locale data,
10
+ # making instance creation non-trivial. This cache stores instances
11
+ # keyed by locale and options to avoid repeated instantiation.
12
+ #
13
+ # Thread safety is provided by Dry::Core::Cache, which uses
14
+ # Concurrent::Map internally.
15
+ #
16
+ # @example
17
+ # cache = Foxtail::ICU4XCache.instance
18
+ # formatter = cache.number_formatter(locale)
19
+ # formatter.format(1234) #=> "1,234"
20
+ class ICU4XCache
21
+ extend Dry::Core::Cache
22
+ include Singleton
23
+
24
+ # Returns a cached ICU4X::NumberFormat instance.
25
+ #
26
+ # @param locale [ICU4X::Locale] The locale for formatting
27
+ # @param options [Hash] Formatting options passed to ICU4X::NumberFormat.new
28
+ # @return [ICU4X::NumberFormat] Cached formatter instance
29
+ def number_formatter(locale, **options)
30
+ self.class.fetch_or_store(:number_formatter, locale, options) do
31
+ ICU4X::NumberFormat.new(locale, **options)
32
+ end
33
+ end
34
+
35
+ # Returns a cached ICU4X::DateTimeFormat instance.
36
+ #
37
+ # @param locale [ICU4X::Locale] The locale for formatting
38
+ # @param options [Hash] Formatting options passed to ICU4X::DateTimeFormat.new
39
+ # @return [ICU4X::DateTimeFormat] Cached formatter instance
40
+ def datetime_formatter(locale, **options)
41
+ self.class.fetch_or_store(:datetime_formatter, locale, options) do
42
+ ICU4X::DateTimeFormat.new(locale, **options)
43
+ end
44
+ end
45
+
46
+ # Returns a cached ICU4X::PluralRules instance.
47
+ #
48
+ # @param locale [ICU4X::Locale] The locale for plural rules
49
+ # @param type [Symbol] Plural rule type (:cardinal or :ordinal)
50
+ # @return [ICU4X::PluralRules] Cached rules instance
51
+ def plural_rules(locale, type: :cardinal)
52
+ self.class.fetch_or_store(:plural_rules, locale, type) do
53
+ ICU4X::PluralRules.new(locale, type:)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ # Container for parsed FTL entries (messages and terms).
5
+ #
6
+ # Created via {.from_string} or {.from_file}, which use the runtime parser
7
+ # ({Bundle::Parser}) optimized for performance with error recovery.
8
+ # Invalid entries are silently skipped; comments are not preserved.
9
+ #
10
+ # For full AST with source positions, comments, and error details,
11
+ # use {Syntax::Parser} instead.
12
+ class Resource
13
+ include Enumerable
14
+
15
+ # @return [Array<Bundle::Parser::AST::Message, Bundle::Parser::AST::Term>] Parsed FTL entries (messages and terms)
16
+ attr_reader :entries
17
+
18
+ # Parse FTL source string into a Resource
19
+ #
20
+ # @param source [String] FTL source text to parse
21
+ # @return [Foxtail::Resource] New resource with parsed entries
22
+ #
23
+ # @example Parse FTL content
24
+ # source = <<~FTL
25
+ # hello = Hello, {$name}!
26
+ # goodbye = Goodbye!
27
+ # FTL
28
+ # resource = Foxtail::Resource.from_string(source)
29
+ # @raise [ArgumentError] if source is not a String
30
+ def self.from_string(source)
31
+ raise ArgumentError, "source must be a String, got #{source.class}" unless source.is_a?(String)
32
+
33
+ parser = Bundle::Parser.new
34
+ entries = parser.parse(source)
35
+
36
+ new(entries)
37
+ end
38
+
39
+ # Parse FTL file into a Resource
40
+ #
41
+ # @param path [Pathname] Path to FTL file
42
+ # @return [Foxtail::Resource] New resource with parsed entries
43
+ def self.from_file(path)
44
+ source = path.read
45
+ from_string(source)
46
+ end
47
+
48
+ def initialize(entries)
49
+ @entries = entries
50
+ end
51
+
52
+ private_class_method :new
53
+
54
+ # Check if resource has any entries
55
+ # @return [Boolean]
56
+ def empty? = @entries.empty?
57
+
58
+ # Get the number of entries
59
+ # @return [Integer]
60
+ def size = @entries.size
61
+
62
+ # Iterate over entries
63
+ # @return [self]
64
+ def each(&)
65
+ @entries.each(&)
66
+ self
67
+ end
68
+
69
+ # Get message entries (IDs not starting with "-")
70
+ # @return [Array<Bundle::Parser::AST::Message>]
71
+ def messages = @entries.select {|entry| entry.id && !entry.id.start_with?("-") }
72
+
73
+ # Get term entries (IDs starting with "-")
74
+ # @return [Array<Bundle::Parser::AST::Term>]
75
+ def terms = @entries.select {|entry| entry.id&.start_with?("-") }
76
+
77
+ # Find entry by ID
78
+ # @return [Bundle::Parser::AST::Message, Bundle::Parser::AST::Term, nil]
79
+ def find(id) = @entries.find {|entry| entry.id == id }
80
+ end
81
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ module Runtime
5
+ # Current version of the Foxtail runtime gem
6
+ VERSION = "0.5.0"
7
+ public_constant :VERSION
8
+ end
9
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foxtail
4
+ # Manages ordered sequences of Bundles for language fallback.
5
+ #
6
+ # @example Basic usage
7
+ # sequence = Foxtail::Sequence.new(bundle_en_us, bundle_en, bundle_default)
8
+ # sequence.format("hello", name: "World")
9
+ #
10
+ # @example Finding the bundle that contains a message
11
+ # bundle = sequence.find("hello")
12
+ # puts "Using locale: #{bundle.locale}" if bundle
13
+ #
14
+ # @see https://projectfluent.org/fluent.js/sequence/
15
+ class Sequence
16
+ # Creates a new Sequence with the given bundles.
17
+ #
18
+ # @param bundles [Array<Bundle>] Bundles in priority order (first = highest priority)
19
+ def initialize(*bundles)
20
+ @bundles = bundles.flatten.freeze
21
+ end
22
+
23
+ # Finds the first bundle that contains a message with the given ID(s).
24
+ #
25
+ # @param ids [Array<String>] One or more message IDs to find
26
+ # @return [Bundle, nil] The first bundle containing the message (single ID)
27
+ # @return [Array<Bundle, nil>] Array of bundles for each ID (multiple IDs)
28
+ def find(*ids)
29
+ if ids.size == 1
30
+ find_bundle(ids.first)
31
+ else
32
+ ids.map {|id| find_bundle(id) }
33
+ end
34
+ end
35
+
36
+ # Formats a message using the first bundle that contains it.
37
+ # Keyword arguments are passed through to the bundle's format method.
38
+ #
39
+ # @param id [String] The message ID
40
+ # @param errors [Array, nil] If provided, errors are collected into this array instead of being ignored.
41
+ # @return [String] The formatted message, or the ID if not found
42
+ def format(id, errors=nil, **)
43
+ bundle = find_bundle(id)
44
+ bundle ? bundle.format(id, errors, **) : id.to_s
45
+ end
46
+
47
+ private def find_bundle(id) = @bundles.find {|bundle| bundle.message?(id) }
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "icu4x"
4
+ require "zeitwerk"
5
+ require_relative "foxtail/runtime/version"
6
+
7
+ # Ruby implementation of Project Fluent localization system
8
+ module Foxtail
9
+ # Configure Zeitwerk loader for this gem
10
+ loader = Zeitwerk::Loader.new
11
+ loader.push_dir(__dir__ + "/foxtail", namespace: Foxtail)
12
+
13
+ # Ignore version.rb since it's required by gemspec before Zeitwerk loads
14
+ loader.ignore(__dir__ + "/foxtail/runtime/version.rb")
15
+ # Ignore gem entrypoint file (no constant defined)
16
+ loader.ignore(__dir__ + "/foxtail.rb")
17
+ loader.ignore(__dir__ + "/foxtail-runtime.rb")
18
+
19
+ # Configure inflections for acronyms
20
+ loader.inflector.inflect(
21
+ "ast" => "AST",
22
+ "datetime" => "DateTime",
23
+ "icu4x_cache" => "ICU4XCache"
24
+ )
25
+
26
+ loader.setup
27
+ end
data/lib/foxtail.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "foxtail-runtime"
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: foxtail-runtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - OZAWA Sakuro
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bigdecimal
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-core
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: icu4x
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.9'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: zeitwerk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.7'
69
+ description: 'Runtime components for Foxtail: bundle parsing, message formatting,
70
+ and ICU4X integration.
71
+
72
+ '
73
+ email:
74
+ - 10973+sakuro@users.noreply.github.com
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - CHANGELOG.md
80
+ - LICENSE.txt
81
+ - README.md
82
+ - lib/foxtail-runtime.rb
83
+ - lib/foxtail.rb
84
+ - lib/foxtail/bundle.rb
85
+ - lib/foxtail/bundle/parser.rb
86
+ - lib/foxtail/bundle/parser/ast.rb
87
+ - lib/foxtail/bundle/resolver.rb
88
+ - lib/foxtail/bundle/scope.rb
89
+ - lib/foxtail/error.rb
90
+ - lib/foxtail/function.rb
91
+ - lib/foxtail/function/datetime.rb
92
+ - lib/foxtail/function/number.rb
93
+ - lib/foxtail/function/value.rb
94
+ - lib/foxtail/icu4x_cache.rb
95
+ - lib/foxtail/resource.rb
96
+ - lib/foxtail/runtime/version.rb
97
+ - lib/foxtail/sequence.rb
98
+ homepage: https://github.com/sakuro/foxtail
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://github.com/sakuro/foxtail
103
+ source_code_uri: https://github.com/sakuro/foxtail.git
104
+ changelog_uri: https://github.com/sakuro/foxtail/blob/main/foxtail-runtime/CHANGELOG.md
105
+ rubygems_mfa_required: 'true'
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '3.3'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.5.22
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: Foxtail runtime for Project Fluent localization
125
+ test_files: []