string_to_number 0.1.4 → 0.2.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 +4 -4
- data/.tool-versions +1 -0
- data/CLAUDE.md +103 -0
- data/Gemfile.lock +2 -2
- data/README.md +177 -22
- data/benchmark.rb +177 -0
- data/lib/string_to_number/parser.rb +230 -0
- data/lib/string_to_number/to_number.rb +137 -30
- data/lib/string_to_number/version.rb +1 -1
- data/lib/string_to_number.rb +90 -3
- data/microbenchmark.rb +226 -0
- data/performance_comparison.rb +155 -0
- data/profile.rb +131 -0
- metadata +9 -2
@@ -0,0 +1,230 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StringToNumber
|
4
|
+
# High-performance French text to number parser
|
5
|
+
#
|
6
|
+
# This class provides a clean, optimized implementation that maintains
|
7
|
+
# compatibility with the original algorithm while adding significant
|
8
|
+
# performance improvements through caching and memoization.
|
9
|
+
#
|
10
|
+
# @example Basic usage
|
11
|
+
# parser = StringToNumber::Parser.new
|
12
|
+
# parser.parse('vingt et un') #=> 21
|
13
|
+
# parser.parse('trois millions') #=> 3_000_000
|
14
|
+
#
|
15
|
+
# @example Class method usage
|
16
|
+
# StringToNumber::Parser.convert('mille deux cent') #=> 1200
|
17
|
+
#
|
18
|
+
class Parser
|
19
|
+
# Import the proven data structures from the original implementation
|
20
|
+
WORD_VALUES = StringToNumber::ToNumber::EXCEPTIONS.freeze
|
21
|
+
MULTIPLIERS = StringToNumber::ToNumber::POWERS_OF_TEN.freeze
|
22
|
+
|
23
|
+
# Pre-compiled regex patterns for optimal performance
|
24
|
+
MULTIPLIER_KEYS = MULTIPLIERS.keys.reject { |k| %w[un dix].include?(k) }
|
25
|
+
.sort_by(&:length).reverse.freeze
|
26
|
+
MULTIPLIER_PATTERN = /(?<f>.*?)\s?(?<m>#{MULTIPLIER_KEYS.join('|')})/
|
27
|
+
QUATRE_VINGT_PATTERN = /(quatre(-|\s)vingt(s?)((-|\s)dix)?)((-|\s)?)(\w*)/
|
28
|
+
|
29
|
+
# Cache configuration
|
30
|
+
MAX_CACHE_SIZE = 1000
|
31
|
+
private_constant :MAX_CACHE_SIZE
|
32
|
+
|
33
|
+
# Thread-safe class-level caches
|
34
|
+
@conversion_cache = {}
|
35
|
+
@cache_access_order = []
|
36
|
+
@instance_cache = {}
|
37
|
+
@cache_mutex = Mutex.new
|
38
|
+
@instance_mutex = Mutex.new
|
39
|
+
|
40
|
+
class << self
|
41
|
+
# Convert French text to number using cached parser instance
|
42
|
+
#
|
43
|
+
# @param text [String] French number text to convert
|
44
|
+
# @return [Integer] The numeric value
|
45
|
+
# @raise [ArgumentError] if text is not a string
|
46
|
+
def convert(text)
|
47
|
+
validate_input!(text)
|
48
|
+
|
49
|
+
normalized = normalize_text(text)
|
50
|
+
return 0 if normalized.empty?
|
51
|
+
|
52
|
+
# Check conversion cache first
|
53
|
+
cached_result = get_cached_conversion(normalized)
|
54
|
+
return cached_result if cached_result
|
55
|
+
|
56
|
+
# Get or create parser instance and convert
|
57
|
+
parser = get_cached_instance(normalized)
|
58
|
+
result = parser.parse_optimized(normalized)
|
59
|
+
|
60
|
+
# Cache the result
|
61
|
+
cache_conversion(normalized, result)
|
62
|
+
result
|
63
|
+
end
|
64
|
+
|
65
|
+
# Clear all caches
|
66
|
+
def clear_caches!
|
67
|
+
@cache_mutex.synchronize do
|
68
|
+
@conversion_cache.clear
|
69
|
+
@cache_access_order.clear
|
70
|
+
end
|
71
|
+
|
72
|
+
@instance_mutex.synchronize do
|
73
|
+
@instance_cache.clear
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Get cache statistics
|
78
|
+
def cache_stats
|
79
|
+
@cache_mutex.synchronize do
|
80
|
+
{
|
81
|
+
conversion_cache_size: @conversion_cache.size,
|
82
|
+
conversion_cache_limit: MAX_CACHE_SIZE,
|
83
|
+
instance_cache_size: @instance_cache.size,
|
84
|
+
cache_hit_ratio: calculate_hit_ratio
|
85
|
+
}
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def validate_input!(text)
|
92
|
+
raise ArgumentError, 'Input must be a string' unless text.respond_to?(:to_s)
|
93
|
+
end
|
94
|
+
|
95
|
+
def normalize_text(text)
|
96
|
+
text.to_s.downcase.strip
|
97
|
+
end
|
98
|
+
|
99
|
+
def get_cached_conversion(normalized_text)
|
100
|
+
@cache_mutex.synchronize do
|
101
|
+
if @conversion_cache.key?(normalized_text)
|
102
|
+
# Update LRU order
|
103
|
+
@cache_access_order.delete(normalized_text)
|
104
|
+
@cache_access_order.push(normalized_text)
|
105
|
+
return @conversion_cache[normalized_text]
|
106
|
+
end
|
107
|
+
end
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
|
111
|
+
def cache_conversion(normalized_text, result)
|
112
|
+
@cache_mutex.synchronize do
|
113
|
+
# LRU eviction
|
114
|
+
if @conversion_cache.size >= MAX_CACHE_SIZE
|
115
|
+
oldest = @cache_access_order.shift
|
116
|
+
@conversion_cache.delete(oldest)
|
117
|
+
end
|
118
|
+
|
119
|
+
@conversion_cache[normalized_text] = result
|
120
|
+
@cache_access_order.push(normalized_text)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def get_cached_instance(normalized_text)
|
125
|
+
@instance_mutex.synchronize do
|
126
|
+
@instance_cache[normalized_text] ||= new(normalized_text)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def calculate_hit_ratio
|
131
|
+
return 0.0 if @cache_access_order.empty?
|
132
|
+
@conversion_cache.size.to_f / @cache_access_order.size
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Initialize parser with normalized text
|
137
|
+
def initialize(text = '')
|
138
|
+
@normalized_text = self.class.send(:normalize_text, text)
|
139
|
+
end
|
140
|
+
|
141
|
+
# Parse the text to numeric value
|
142
|
+
def parse
|
143
|
+
self.class.convert(@normalized_text)
|
144
|
+
end
|
145
|
+
|
146
|
+
# Internal optimized parsing method using the original proven algorithm
|
147
|
+
# but with performance optimizations
|
148
|
+
def parse_optimized(text)
|
149
|
+
return 0 if text.nil? || text.empty?
|
150
|
+
|
151
|
+
# Direct lookup (fastest path)
|
152
|
+
return WORD_VALUES[text] if WORD_VALUES.key?(text)
|
153
|
+
|
154
|
+
# Use the proven extraction algorithm from the original implementation
|
155
|
+
extract_optimized(text, MULTIPLIER_KEYS.join('|'))
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
# Optimized version of the original extract method
|
161
|
+
# This maintains the exact logic of the working implementation
|
162
|
+
# but with performance improvements
|
163
|
+
def extract_optimized(sentence, keys, detail: false)
|
164
|
+
return 0 if sentence.nil? || sentence.empty?
|
165
|
+
|
166
|
+
# Direct lookup
|
167
|
+
return WORD_VALUES[sentence] if WORD_VALUES.key?(sentence)
|
168
|
+
|
169
|
+
# Main pattern matching using pre-compiled regex
|
170
|
+
if result = MULTIPLIER_PATTERN.match(sentence)
|
171
|
+
# Remove matched portion
|
172
|
+
sentence = sentence.gsub(result[0], '') if result[0]
|
173
|
+
|
174
|
+
# Extract factor
|
175
|
+
factor = WORD_VALUES[result[:f]] || match_optimized(result[:f])
|
176
|
+
factor = 1 if factor.zero? && !detail
|
177
|
+
multiple_of_ten = 10**(MULTIPLIERS[result[:m]] || 0)
|
178
|
+
|
179
|
+
# Handle compound numbers
|
180
|
+
if higher_multiple_exists?(result[:m], sentence)
|
181
|
+
details = extract_optimized(sentence, keys, detail: true)
|
182
|
+
factor = (factor * multiple_of_ten) + details[:factor]
|
183
|
+
multiple_of_ten = details[:multiple_of_ten]
|
184
|
+
sentence = details[:sentence]
|
185
|
+
end
|
186
|
+
|
187
|
+
# Return based on mode
|
188
|
+
if detail
|
189
|
+
return {
|
190
|
+
factor: factor,
|
191
|
+
multiple_of_ten: multiple_of_ten,
|
192
|
+
sentence: sentence
|
193
|
+
}
|
194
|
+
end
|
195
|
+
|
196
|
+
return extract_optimized(sentence, keys) + factor * multiple_of_ten
|
197
|
+
|
198
|
+
# Quatre-vingt special handling
|
199
|
+
elsif m = QUATRE_VINGT_PATTERN.match(sentence)
|
200
|
+
normalize_str = m[1].tr(' ', '-')
|
201
|
+
normalize_str = normalize_str[0...-1] if normalize_str[-1] == 's'
|
202
|
+
|
203
|
+
sentence = sentence.gsub(m[0], '')
|
204
|
+
|
205
|
+
return extract_optimized(sentence, keys) +
|
206
|
+
WORD_VALUES[normalize_str] + (WORD_VALUES[m[8]] || 0)
|
207
|
+
else
|
208
|
+
return match_optimized(sentence)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
# Optimized match method
|
213
|
+
def match_optimized(sentence)
|
214
|
+
return 0 if sentence.nil?
|
215
|
+
|
216
|
+
sentence.tr('-', ' ').split(' ').reverse.sum do |word|
|
217
|
+
next 0 if word == 'et'
|
218
|
+
WORD_VALUES[word] || (MULTIPLIERS[word] ? 10 * MULTIPLIERS[word] : 0)
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Optimized higher multiple check
|
223
|
+
def higher_multiple_exists?(multiple, sentence)
|
224
|
+
current_power = MULTIPLIERS[multiple]
|
225
|
+
MULTIPLIERS.any? do |word, power|
|
226
|
+
power > current_power && sentence.include?(word)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
@@ -1,13 +1,23 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module StringToNumber
|
4
|
+
# ToNumber class handles the conversion of French text to numbers
|
5
|
+
# It uses a complex recursive parsing algorithm to handle French number grammar
|
4
6
|
class ToNumber
|
5
7
|
attr_accessor :sentence, :keys
|
6
8
|
|
9
|
+
# EXCEPTIONS contains direct mappings from French words to their numeric values
|
10
|
+
# This includes:
|
11
|
+
# - Basic numbers 0-90
|
12
|
+
# - Feminine forms ("une" for "un")
|
13
|
+
# - Regional variations (Belgian/Swiss French: "septante", "huitante", "nonante")
|
14
|
+
# - Special cases for "quatre-vingt" variations with/without 's'
|
15
|
+
# - Compound numbers like "dix-sept", "soixante-dix"
|
7
16
|
EXCEPTIONS = {
|
8
|
-
'zéro' => 0,
|
9
|
-
'zero' => 0,
|
10
|
-
'un' => 1,
|
17
|
+
'zéro' => 0, # Zero with accent
|
18
|
+
'zero' => 0, # Zero without accent
|
19
|
+
'un' => 1, # Masculine "one"
|
20
|
+
'une' => 1, # Feminine "one"
|
11
21
|
'deux' => 2,
|
12
22
|
'trois' => 3,
|
13
23
|
'quatre' => 4,
|
@@ -23,29 +33,44 @@ module StringToNumber
|
|
23
33
|
'quatorze' => 14,
|
24
34
|
'quinze' => 15,
|
25
35
|
'seize' => 16,
|
26
|
-
'dix-sept' => 17,
|
27
|
-
'dix-huit' => 18,
|
28
|
-
'dix-neuf' => 19,
|
36
|
+
'dix-sept' => 17, # Compound: "ten-seven"
|
37
|
+
'dix-huit' => 18, # Compound: "ten-eight"
|
38
|
+
'dix-neuf' => 19, # Compound: "ten-nine"
|
29
39
|
'vingt' => 20,
|
30
40
|
'trente' => 30,
|
31
41
|
'quarante' => 40,
|
32
42
|
'cinquante' => 50,
|
33
43
|
'soixante' => 60,
|
34
|
-
'soixante-dix' => 70,
|
35
|
-
'
|
36
|
-
'quatre-
|
37
|
-
'quatre-vingt
|
38
|
-
'
|
44
|
+
'soixante-dix' => 70, # Standard French: "sixty-ten"
|
45
|
+
'septante' => 70, # Belgian/Swiss French alternative
|
46
|
+
'quatre-vingts' => 80, # Standard French: "four-twenties" (plural)
|
47
|
+
'quatre-vingt' => 80, # Standard French: "four-twenty" (singular)
|
48
|
+
'huitante' => 80, # Swiss French alternative
|
49
|
+
'quatre-vingt-dix' => 90, # Standard French: "four-twenty-ten"
|
50
|
+
'quatre-vingts-dix' => 90,# Alternative with plural "vingts"
|
51
|
+
'nonante' => 90 # Belgian/Swiss French alternative
|
39
52
|
}.freeze
|
40
53
|
|
54
|
+
# POWERS_OF_TEN maps French number words to their power of 10 exponents
|
55
|
+
# Used for multipliers like "cent" (10^2), "mille" (10^3), "million" (10^6)
|
56
|
+
# Includes both singular and plural forms for proper French grammar
|
57
|
+
# Uses French number scale where "billion" = 10^12 (not 10^9 as in English)
|
41
58
|
POWERS_OF_TEN = {
|
42
|
-
'un' => 0,
|
43
|
-
'dix' => 1,
|
44
|
-
'cent' => 2,
|
45
|
-
'
|
46
|
-
'
|
47
|
-
'
|
48
|
-
'
|
59
|
+
'un' => 0, # 10^0 = 1 (ones place)
|
60
|
+
'dix' => 1, # 10^1 = 10 (tens place)
|
61
|
+
'cent' => 2, # 10^2 = 100 (hundreds, singular)
|
62
|
+
'cents' => 2, # 10^2 = 100 (hundreds, plural)
|
63
|
+
'mille' => 3, # 10^3 = 1,000 (thousands, singular)
|
64
|
+
'milles' => 3, # 10^3 = 1,000 (thousands, plural)
|
65
|
+
'million' => 6, # 10^6 = 1,000,000 (millions, singular)
|
66
|
+
'millions' => 6, # 10^6 = 1,000,000 (millions, plural)
|
67
|
+
'milliard' => 9, # 10^9 = 1,000,000,000 (French billion, singular)
|
68
|
+
'milliards' => 9, # 10^9 = 1,000,000,000 (French billion, plural)
|
69
|
+
'billion' => 12, # 10^12 = 1,000,000,000,000 (French trillion, singular)
|
70
|
+
'billions' => 12, # 10^12 = 1,000,000,000,000 (French trillion, plural)
|
71
|
+
'trillion' => 15, # 10^15 (French quadrillion, singular)
|
72
|
+
'trillions' => 15, # 10^15 (French quadrillion, plural)
|
73
|
+
# Extended list of large number names for completeness
|
49
74
|
'quadrillion' => 15,
|
50
75
|
'quintillion' => 18,
|
51
76
|
'sextillion' => 21,
|
@@ -75,42 +100,88 @@ module StringToNumber
|
|
75
100
|
'trigintillion' => 93,
|
76
101
|
'untrigintillion' => 96,
|
77
102
|
'duotrigintillion' => 99,
|
78
|
-
'googol' => 100
|
103
|
+
'googol' => 100 # Special case: 10^100
|
79
104
|
}.freeze
|
80
105
|
|
106
|
+
# Initialize the ToNumber parser with a French sentence
|
107
|
+
# @param sentence [String] The French text to be converted to numbers
|
81
108
|
def initialize(sentence = '')
|
82
|
-
|
83
|
-
|
109
|
+
# Create regex pattern from POWERS_OF_TEN keys, excluding 'un' and 'dix'
|
110
|
+
# which are handled differently in the parsing logic
|
111
|
+
# Sort keys by length (longest first) to ensure longer matches are preferred
|
112
|
+
# This prevents "cent" from matching before "cents" in "cinq cents"
|
113
|
+
sorted_keys = POWERS_OF_TEN.keys.reject { |k| %w[un dix].include?(k) }.sort_by(&:length).reverse
|
114
|
+
@keys = sorted_keys.join('|') # Create regex alternation pattern
|
115
|
+
# Normalize input to lowercase for case-insensitive matching
|
116
|
+
@sentence = sentence&.downcase || ''
|
84
117
|
end
|
85
118
|
|
119
|
+
# Main entry point to convert the French sentence to a number
|
120
|
+
# @return [Integer] The numeric value of the French text
|
86
121
|
def to_number
|
87
122
|
extract(@sentence, keys)
|
88
123
|
end
|
89
124
|
|
90
125
|
private
|
91
126
|
|
127
|
+
# Main recursive extraction method that parses French number patterns
|
128
|
+
# This is the core of the parsing algorithm
|
129
|
+
# @param sentence [String] The French text to parse
|
130
|
+
# @param keys [String] Regex pattern of power-of-ten multipliers
|
131
|
+
# @param detail [Boolean] If true, returns detailed parsing info for recursion
|
132
|
+
# @return [Integer, Hash] Numeric value or detailed parsing hash
|
92
133
|
def extract(sentence, keys, detail: false)
|
134
|
+
# Base cases: handle empty/nil input
|
93
135
|
return 0 if sentence.nil? || sentence.empty?
|
136
|
+
|
137
|
+
# Ensure case-insensitive matching
|
138
|
+
sentence = sentence.downcase
|
139
|
+
|
140
|
+
# Direct lookup for simple cases (e.g., "vingt" -> 20)
|
94
141
|
return EXCEPTIONS[sentence] unless EXCEPTIONS[sentence].nil?
|
95
142
|
|
143
|
+
# Main parsing logic: look for pattern "factor + multiplier"
|
144
|
+
# Example: "cinq cents" -> factor="cinq", multiplier="cents"
|
145
|
+
# Regex explanation:
|
146
|
+
# (?<f>.*?) - Non-greedy capture of factor part (before multiplier)
|
147
|
+
# \s? - Optional space
|
148
|
+
# (?<m>#{keys}) - Named capture of multiplier from keys pattern
|
96
149
|
if result = /(?<f>.*?)\s?(?<m>#{keys})/.match(sentence)
|
97
|
-
#
|
150
|
+
# Remove the matched portion from sentence for further processing
|
98
151
|
sentence.gsub!($&, '') if $&
|
99
152
|
|
100
|
-
#
|
101
|
-
|
102
|
-
factor
|
153
|
+
# Parse the factor part (number before the multiplier)
|
154
|
+
# Example: "cinq" -> 5, "deux cent" -> 200
|
155
|
+
factor = EXCEPTIONS[result[:f]] || match(result[:f])
|
156
|
+
|
157
|
+
# Handle implicit factor of 1 for standalone multipliers
|
158
|
+
# Example: "million" -> factor=1, but only for top-level calls
|
159
|
+
# For recursive calls (detail=true), keep factor as 0 to avoid double-counting
|
160
|
+
factor = 1 if factor.zero? && !detail
|
161
|
+
|
162
|
+
# Calculate the multiplier value (10^exponent)
|
163
|
+
# Example: "cents" -> 10^2 = 100, "millions" -> 10^6 = 1,000,000
|
103
164
|
multiple_of_ten = 10**(POWERS_OF_TEN[result[:m]] || 0)
|
104
165
|
|
105
|
-
#
|
166
|
+
# Handle compound numbers with higher-order multipliers
|
167
|
+
# Example: "cinq cents millions" - after matching "cinq cents",
|
168
|
+
# check if "millions" (a higher multiplier than "cents") remains
|
106
169
|
if /#{higher_multiple(result[:m]).keys.join('|')}/.match(sentence)
|
170
|
+
# Recursively process the higher multiplier
|
107
171
|
details = extract(sentence, keys, detail: true)
|
108
172
|
|
109
|
-
|
173
|
+
# Combine the current factor*multiplier with the higher multiplier
|
174
|
+
# Example: For "cinq cents millions":
|
175
|
+
# - factor = 5, multiple_of_ten = 100 (from "cinq cents")
|
176
|
+
# - details[:factor] = 0, details[:multiple_of_ten] = 1000000 (from "millions")
|
177
|
+
# - result: factor = (5 * 100) + 0 = 500, multiple_of_ten = 1000000
|
178
|
+
# - final: 500 * 1000000 = 500,000,000
|
179
|
+
factor = (factor * multiple_of_ten) + details[:factor]
|
110
180
|
multiple_of_ten = details[:multiple_of_ten]
|
111
|
-
sentence
|
181
|
+
sentence = details[:sentence]
|
112
182
|
end
|
113
183
|
|
184
|
+
# Return detailed parsing info for recursive calls
|
114
185
|
if detail
|
115
186
|
return {
|
116
187
|
factor: factor,
|
@@ -119,33 +190,69 @@ module StringToNumber
|
|
119
190
|
}
|
120
191
|
end
|
121
192
|
|
193
|
+
# Final calculation: process any remaining sentence + current factor*multiplier
|
194
|
+
# Example: For "trois millions cinq cents", this handles the "cinq cents" part
|
122
195
|
return extract(sentence, keys) + factor * multiple_of_ten
|
123
196
|
|
197
|
+
# Special case handling for "quatre-vingt" variations
|
198
|
+
# This complex regex handles the irregular French "eighty" patterns:
|
199
|
+
# - "quatre-vingt" / "quatre vingts" (with/without 's')
|
200
|
+
# - "quatre-vingt-dix" / "quatre vingts dix" (90)
|
201
|
+
# - Space vs hyphen variations
|
124
202
|
elsif m = /(quatre(-|\s)vingt(s?)((-|\s)dix)?)((-|\s)?)(\w*)/.match(sentence)
|
203
|
+
# Normalize spacing to hyphens for consistent lookup
|
125
204
|
normalize_str = m[1].tr(' ', '-')
|
126
|
-
|
205
|
+
|
206
|
+
# Remove trailing 's' from "quatre-vingts" if present
|
207
|
+
# Bug fix: use [-1] instead of [length] for last character
|
208
|
+
normalize_str = normalize_str[0...-1] if normalize_str[-1] == 's'
|
127
209
|
|
210
|
+
# Remove the matched portion from sentence
|
128
211
|
sentence.gsub!(m[0], '')
|
129
212
|
|
213
|
+
# Return sum of: remaining sentence + normalized quatre-vingt value + any suffix
|
214
|
+
# Example: "quatre-vingt-cinq" -> EXCEPTIONS["quatre-vingt"] + EXCEPTIONS["cinq"]
|
130
215
|
return extract(sentence, keys) +
|
131
216
|
EXCEPTIONS[normalize_str] + (EXCEPTIONS[m[8]] || 0)
|
132
217
|
else
|
218
|
+
# Fallback: use match() method for simple word combinations
|
133
219
|
return match(sentence)
|
134
220
|
end
|
135
221
|
end
|
136
222
|
|
223
|
+
# Fallback method for parsing simple word sequences
|
224
|
+
# Used when the main extract() method can't find multiplier patterns
|
225
|
+
# @param sentence [String] French text to parse as individual words
|
226
|
+
# @return [Integer, nil] Sum of individual word values or nil if no sentence
|
137
227
|
def match(sentence)
|
138
228
|
return if sentence.nil?
|
139
229
|
|
140
|
-
|
230
|
+
# Process words in reverse order for proper French number logic
|
231
|
+
# Example: "vingt et un" -> ["un", "et", "vingt"] -> 1 + 0 + 20 = 21
|
232
|
+
sentence.downcase.tr('-', ' ').split(' ').reverse.sum do |word|
|
233
|
+
# Handle French "et" (and) conjunction by ignoring it in calculations
|
234
|
+
# Example: "vingt et un" -> ignore "et", sum "vingt" + "un"
|
235
|
+
next 0 if word == 'et'
|
236
|
+
|
237
|
+
# Look up word value in either EXCEPTIONS or POWERS_OF_TEN
|
141
238
|
if EXCEPTIONS[word].nil? && POWERS_OF_TEN[word].nil?
|
239
|
+
# Unknown words contribute 0 to the sum
|
142
240
|
0
|
143
241
|
else
|
242
|
+
# Use EXCEPTIONS value if available, otherwise use 10 * power_of_ten
|
243
|
+
# Example: "dix" -> EXCEPTIONS["dix"] = 10
|
244
|
+
# "cent" -> 10 * POWERS_OF_TEN["cent"] = 10 * 2 = 100
|
144
245
|
(EXCEPTIONS[word] || (10 * POWERS_OF_TEN[word]))
|
145
246
|
end
|
146
247
|
end
|
147
248
|
end
|
148
249
|
|
250
|
+
# Helper method to find multipliers with higher powers than the given one
|
251
|
+
# Used to detect when compound numbers have higher-order multipliers
|
252
|
+
# @param multiple [String] The current multiplier word (e.g., "cents")
|
253
|
+
# @return [Hash] Hash of multipliers with higher powers of 10
|
254
|
+
# Example: higher_multiple("cents") returns {"mille"=>3, "million"=>6, ...}
|
255
|
+
# because 10^3, 10^6, etc. are all > 10^2 (cents)
|
149
256
|
def higher_multiple(multiple)
|
150
257
|
POWERS_OF_TEN.select do |_k, v|
|
151
258
|
v > POWERS_OF_TEN[multiple]
|
data/lib/string_to_number.rb
CHANGED
@@ -1,10 +1,97 @@
|
|
1
1
|
require 'string_to_number/version'
|
2
|
+
|
3
|
+
# Load original implementation first for constant definitions
|
2
4
|
require 'string_to_number/to_number'
|
3
5
|
|
6
|
+
# Then load optimized implementation
|
7
|
+
require 'string_to_number/parser'
|
8
|
+
|
4
9
|
module StringToNumber
|
10
|
+
# Main interface for converting French text to numbers
|
11
|
+
#
|
12
|
+
# This module provides a simple interface to the high-performance French
|
13
|
+
# number parser with backward compatibility options.
|
14
|
+
#
|
15
|
+
# @example Basic usage
|
16
|
+
# StringToNumber.in_numbers('vingt et un') #=> 21
|
17
|
+
# StringToNumber.in_numbers('trois millions') #=> 3_000_000
|
18
|
+
#
|
19
|
+
# @example Backward compatibility
|
20
|
+
# StringToNumber.in_numbers('cent', use_optimized: false) #=> 100
|
21
|
+
#
|
5
22
|
class << self
|
6
|
-
|
7
|
-
|
23
|
+
# Convert French text to number
|
24
|
+
#
|
25
|
+
# @param sentence [String] French number text to convert
|
26
|
+
# @param use_optimized [Boolean] Whether to use optimized parser (default: true)
|
27
|
+
# @return [Integer] The numeric value
|
28
|
+
# @raise [ArgumentError] if sentence is not convertible to string
|
29
|
+
#
|
30
|
+
# @example Standard usage
|
31
|
+
# in_numbers('vingt et un') #=> 21
|
32
|
+
#
|
33
|
+
# @example Using original implementation
|
34
|
+
# in_numbers('cent', use_optimized: false) #=> 100
|
35
|
+
#
|
36
|
+
def in_numbers(sentence, use_optimized: true)
|
37
|
+
if use_optimized
|
38
|
+
Parser.convert(sentence)
|
39
|
+
else
|
40
|
+
# Fallback to original implementation for compatibility testing
|
41
|
+
ToNumber.new(sentence).to_number
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Convert using original implementation (for compatibility testing)
|
46
|
+
#
|
47
|
+
# @param sentence [String] French text to convert
|
48
|
+
# @return [Integer] The numeric value
|
49
|
+
def in_numbers_original(sentence)
|
50
|
+
ToNumber.new(sentence).to_number
|
51
|
+
end
|
52
|
+
|
53
|
+
# Clear all internal caches
|
54
|
+
#
|
55
|
+
# Useful for testing, memory management, or when processing
|
56
|
+
# large volumes of unique inputs.
|
57
|
+
#
|
58
|
+
# @return [void]
|
59
|
+
def clear_caches!
|
60
|
+
Parser.clear_caches!
|
61
|
+
end
|
62
|
+
|
63
|
+
# Get cache performance statistics
|
64
|
+
#
|
65
|
+
# @return [Hash] Cache statistics including sizes and hit ratios
|
66
|
+
# @example
|
67
|
+
# stats = StringToNumber.cache_stats
|
68
|
+
# puts "Cache hit ratio: #{stats[:cache_hit_ratio]}"
|
69
|
+
#
|
70
|
+
def cache_stats
|
71
|
+
Parser.cache_stats
|
72
|
+
end
|
73
|
+
|
74
|
+
# Check if a string contains valid French number words
|
75
|
+
#
|
76
|
+
# @param text [String] Text to validate
|
77
|
+
# @return [Boolean] true if text appears to contain French numbers
|
78
|
+
#
|
79
|
+
def valid_french_number?(text)
|
80
|
+
return false unless text.respond_to?(:to_s)
|
81
|
+
|
82
|
+
normalized = text.to_s.downcase.strip
|
83
|
+
return false if normalized.empty?
|
84
|
+
|
85
|
+
# Check if any words are recognized French number words
|
86
|
+
words = normalized.tr('-', ' ').split(/\s+/)
|
87
|
+
recognized_words = words.count do |word|
|
88
|
+
word == 'et' ||
|
89
|
+
Parser::WORD_VALUES.key?(word) ||
|
90
|
+
Parser::MULTIPLIERS.key?(word)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Require at least 50% recognized words for validation
|
94
|
+
recognized_words.to_f / words.size >= 0.5
|
8
95
|
end
|
9
96
|
end
|
10
|
-
end
|
97
|
+
end
|