Skip to content

Commit 7728b33

Browse files
authored
Add BigDecimal parsing (#385)
1 parent d3b1f82 commit 7728b33

8 files changed

Lines changed: 255 additions & 49 deletions

File tree

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ Metrics/AbcSize:
3232
Enabled: false
3333
Layout/ExtraSpacing:
3434
AllowForAlignment: false
35+
RSpec/DescribeClass:
36+
Enabled: false

.rubocop_todo.yml

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2025-12-28 23:46:28 UTC using RuboCop version 1.82.1.
3+
# on 2025-12-30 15:16:10 UTC using RuboCop version 1.82.1.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
@@ -42,21 +42,6 @@ RSpec/ContextWording:
4242
- 'spec/ruby_units/unit_spec.rb'
4343
- 'spec/ruby_units/utf-8/unit_spec.rb'
4444

45-
# Offense count: 13
46-
# Configuration parameters: IgnoredMetadata.
47-
RSpec/DescribeClass:
48-
Exclude:
49-
- '**/spec/features/**/*'
50-
- '**/spec/requests/**/*'
51-
- '**/spec/routing/**/*'
52-
- '**/spec/system/**/*'
53-
- '**/spec/views/**/*'
54-
- 'spec/ruby_units/bugs_spec.rb'
55-
- 'spec/ruby_units/definition_spec.rb'
56-
- 'spec/ruby_units/initialization_spec.rb'
57-
- 'spec/ruby_units/temperature_spec.rb'
58-
- 'spec/ruby_units/unit_spec.rb'
59-
6045
# Offense count: 1
6146
RSpec/DescribeMethod:
6247
Exclude:
@@ -93,7 +78,7 @@ RSpec/MultipleDescribes:
9378
Exclude:
9479
- 'spec/ruby_units/unit_spec.rb'
9580

96-
# Offense count: 30
81+
# Offense count: 33
9782
RSpec/MultipleExpectations:
9883
Max: 6
9984

lib/ruby_units/configuration.rb

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# It allows for the creation, conversion, and mathematical operations on physical quantities
55
# with associated units of measurement.
66
module RubyUnits
7+
# Raised when a requested feature cannot be enabled because a runtime
8+
# dependency has not been loaded by the caller.
9+
class MissingDependencyError < StandardError; end
710
class << self
811
# Get or initialize the configuration
912
# @return [Configuration] the configuration instance
@@ -88,16 +91,66 @@ class Configuration
8891
# @return [Numeric] the precision to use when converting to a rational (default: 0.0001)
8992
attr_reader :default_precision
9093

91-
# Initialize configuration with keyword arguments
94+
# Whether to parse numeric literals as BigDecimal when parsing unit strings.
95+
# This is an opt-in feature because BigDecimal has different performance
96+
# and precision characteristics compared to Float. The default is `false`.
97+
#
98+
# When enabled, numeric strings parsed from unit inputs will be converted
99+
# to BigDecimal. The caller must require the BigDecimal library before
100+
# enabling this mode (for example `require 'bigdecimal'`).
101+
#
102+
# @!attribute [rw] use_bigdecimal
103+
# @return [Boolean] whether to coerce numeric literals to BigDecimal (default: false)
104+
attr_reader :use_bigdecimal
105+
106+
# Initialize configuration with keyword arguments.
107+
#
108+
# Accepts keyword options to set initial configuration values. Each value
109+
# is validated by the corresponding setter method; invalid values will
110+
# raise an error (see @raise tags below). Boolean values for
111+
# `separator` are accepted for backward compatibility but will emit a
112+
# deprecation warning.
113+
#
114+
# @param opts [Hash] the keyword options hash
115+
# @option opts [Symbol, Boolean] :separator One of `:space` or `:none`.
116+
# Boolean `true`/`false` are accepted for backward compatibility
117+
# (`true` -> `:space`, `false` -> `:none`) and will emit a deprecation
118+
# warning. Internally a `:space` separator is stored as a single space
119+
# string (" ") and `:none` is stored as `nil`. Default: `:space`.
120+
# @option opts [Symbol] :format The output format, one of `:rational` or
121+
# `:exponential`. Default: `:rational`.
122+
# @option opts [Numeric] :default_precision Positive numeric precision
123+
# used when rationalizing fractional values. Default: `0.0001`.
124+
# @option opts [Boolean] :use_bigdecimal When `true`, numeric literals
125+
# parsed from unit input strings will be coerced to `BigDecimal`.
126+
# The caller must require the BigDecimal library before enabling this
127+
# option. Default: `false`.
128+
#
129+
# @raise [ArgumentError] If any provided value fails validation (invalid
130+
# `separator`, invalid `format`, non-positive `default_precision`, or
131+
# non-boolean `use_bigdecimal`).
132+
# @raise [MissingDependencyError] If `use_bigdecimal` is enabled but the
133+
# `BigDecimal` library has not been required.
134+
#
135+
# @example
136+
# Configuration.new(
137+
# separator: :none,
138+
# format: :exponential,
139+
# default_precision: 1e-6,
140+
# use_bigdecimal: false
141+
# )
92142
#
93-
# @param separator [Symbol, Boolean] the separator to use (:space or :none, true/false for backward compatibility) (default: :space)
94-
# @param format [Symbol] the format to use when generating output (:rational or :exponential) (default: :rational)
95-
# @param default_precision [Numeric] the precision to use when converting to a rational (default: 0.0001)
96143
# @return [Configuration] a new configuration instance
97-
def initialize(separator: :space, format: :rational, default_precision: 0.0001)
144+
def initialize(**opts)
145+
separator = opts.fetch(:separator, :space)
146+
format = opts.fetch(:format, :rational)
147+
default_precision = opts.fetch(:default_precision, 0.0001)
148+
use_bigdecimal = opts.fetch(:use_bigdecimal, false)
149+
98150
self.separator = separator
99151
self.format = format
100152
self.default_precision = default_precision
153+
self.use_bigdecimal = use_bigdecimal
101154
end
102155

103156
# Set the separator to use when generating output.
@@ -155,5 +208,28 @@ def default_precision=(value)
155208

156209
@default_precision = value
157210
end
211+
212+
# Enable or disable BigDecimal parsing for numeric literals.
213+
#
214+
# To enable BigDecimal parsing, the BigDecimal library must already be
215+
# required by the application. If you attempt to enable this option
216+
# without requiring BigDecimal first a `MissingDependencyError` will be
217+
# raised to make the dependency requirement explicit.
218+
#
219+
# @param value [Boolean]
220+
# @return [void]
221+
# @raise [ArgumentError] if `value` is not a boolean
222+
# @raise [MissingDependencyError] when enabling without requiring BigDecimal first
223+
# @example
224+
# require 'bigdecimal'
225+
# require 'bigdecimal/util' # for to_d method (optional)
226+
# RubyUnits.configuration.use_bigdecimal = true
227+
def use_bigdecimal=(value)
228+
raise ArgumentError, "configuration 'use_bigdecimal' must be a boolean" unless [true, false].include?(value)
229+
230+
raise MissingDependencyError, "To enable use_bigdecimal, require 'bigdecimal' before setting RubyUnits.configuration.use_bigdecimal = true" if value && !defined?(BigDecimal)
231+
232+
@use_bigdecimal = value
233+
end
158234
end
159235
end

lib/ruby_units/math.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def atan2(x, y)
234234
# :reek:UncommunicativeMethodName
235235
def log10(number)
236236
if number.is_a?(RubyUnits::Unit)
237-
super(number.to_f)
237+
super(number.scalar)
238238
else
239239
super
240240
end
@@ -251,10 +251,10 @@ def log10(number)
251251
# @example
252252
# Math.log(Unit.new("2.718")) #=> ~1.0 (natural log)
253253
# Math.log(Unit.new("8"), 2) #=> 3.0 (log base 2)
254-
# Math.log(Math::E) #=> 1.0
254+
# Math.log(Math::E) #=> 1.0
255255
def log(number, base = ::Math::E)
256256
if number.is_a?(RubyUnits::Unit)
257-
super(number.to_f, base)
257+
super(number.scalar, base)
258258
else
259259
super
260260
end

lib/ruby_units/unit.rb

Lines changed: 89 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,64 @@ def self.base_units
373373
@base_units ||= definitions.dup.select { |_, definition| definition.base? }.keys.map { new(_1) }
374374
end
375375

376+
# Coerce a string or numeric value into the configured numeric type.
377+
#
378+
# When `RubyUnits.configuration.use_bigdecimal` is true, numeric strings are
379+
# converted to BigDecimal (the caller must require 'bigdecimal').
380+
# Otherwise numeric strings are converted to Float. If the input is already
381+
# a Numeric it is returned unchanged.
382+
#
383+
# @param value [String, Numeric] the value to coerce
384+
# @return [Numeric] a Numeric instance (BigDecimal or Float) or the original Numeric
385+
# @raise [ArgumentError] if the value cannot be coerced by the underlying constructors
386+
# @example
387+
# Unit.parse_number("3.14") #=> 3.14 (Float) unless use_bigdecimal is enabled
388+
# Unit.parse_number(2) #=> 2 (unchanged)
389+
def self.parse_number(value)
390+
return value if value.is_a?(Numeric)
391+
392+
if RubyUnits.configuration.use_bigdecimal
393+
BigDecimal(value)
394+
else
395+
Float(value)
396+
end
397+
end
398+
399+
# Return an Integer when the provided numeric value is mathematically
400+
# integral; otherwise return the original numeric value.
401+
#
402+
# The method first prefers `to_int` when available (exact integer
403+
# conversion). If not available it falls back to `to_i` and compares the
404+
# converted integer to the original value. This works for Float, Rational, Complex,
405+
# BigDecimal (if loaded), and Integer.
406+
#
407+
# @param value [Numeric] the numeric value to normalize
408+
# @return [Integer, Numeric] an `Integer` when the value is integral, otherwise the original numeric
409+
# @example
410+
# Unit.normalize_to_i(2.0) #=> 2
411+
# Unit.normalize_to_i(Rational(3,1)) #=> 3
412+
# Unit.normalize_to_i(3.5) #=> 3.5
413+
# :reek:ManualDispatch
414+
def self.normalize_to_i(value)
415+
return value unless value.is_a?(Numeric)
416+
417+
responds_to_int = value.respond_to?(:to_int)
418+
if responds_to_int || value.respond_to?(:to_i)
419+
int = if responds_to_int
420+
value.to_int
421+
else
422+
value.to_i
423+
end
424+
int == value ? int : value
425+
else
426+
value
427+
end
428+
rescue RangeError
429+
# This can happen when a Complex number with a non-zero imaginary part is provided, or when value is Float::NAN or
430+
# Float::INFINITY
431+
value
432+
end
433+
376434
# Parse a string consisting of a number and a unit string
377435
# NOTE: This does not properly handle units formatted like '12mg/6ml'
378436
#
@@ -395,7 +453,7 @@ def self.parse_into_numbers_and_units(string)
395453
fractional_part = Rational(Regexp.last_match(3).to_i, Regexp.last_match(4).to_i)
396454
sign * (whole_part + fractional_part)
397455
else
398-
num.to_f
456+
parse_number(num)
399457
end,
400458
unit.to_s.strip
401459
]
@@ -1239,10 +1297,7 @@ def convert_to(other)
12391297
converted_value = conversion_scalar * (source_numerator_values + target_denominator_values).reduce(1, :*) / (target_numerator_values + source_denominator_values).reduce(1, :*)
12401298
# Convert the scalar to an Integer if the result is equivalent to an
12411299
# integer
1242-
if scalar_is_integer
1243-
converted_as_int = converted_value.to_i
1244-
converted_value = converted_as_int if converted_as_int == converted_value
1245-
end
1300+
converted_value = unit_class.normalize_to_i(converted_value)
12461301
unit_class.new(scalar: converted_value, numerator: target_num, denominator: target_den, signature: target.signature)
12471302
end
12481303
end
@@ -1257,6 +1312,17 @@ def to_f
12571312
return_scalar_or_raise(:to_f, Float)
12581313
end
12591314

1315+
# Convert the unit's scalar to BigDecimal. Raises if not unitless.
1316+
#
1317+
# Note: Using this method requires the BigDecimal class to be available
1318+
# (e.g., by requiring `'bigdecimal'` and `'bigdecimal/util'`).
1319+
#
1320+
# @return [BigDecimal]
1321+
# @raise [RuntimeError] when not unitless
1322+
def to_d
1323+
return_scalar_or_raise(:to_d, BigDecimal)
1324+
end
1325+
12601326
# converts the unit back to a complex if it is unitless. Otherwise raises an exception
12611327
# @return [Complex]
12621328
# @raise [RuntimeError] when not unitless
@@ -2063,12 +2129,10 @@ def parse(passed_unit_string = "0")
20632129
if unit_string.start_with?(COMPLEX_NUMBER)
20642130
match = unit_string.match(COMPLEX_REGEX)
20652131
real_str, imaginary_str, unit_s = match.values_at(:real, :imaginary, :unit)
2066-
real = Float(real_str) if real_str
2067-
imaginary = Float(imaginary_str)
2068-
real_as_int = real.to_i if real
2069-
real = real_as_int if real_as_int == real
2070-
imaginary_as_int = imaginary.to_i
2071-
imaginary = imaginary_as_int if imaginary_as_int == imaginary
2132+
real = unit_class.parse_number(real_str) if real_str
2133+
imaginary = unit_class.parse_number(imaginary_str)
2134+
real = unit_class.normalize_to_i(real) if real
2135+
imaginary = unit_class.normalize_to_i(imaginary)
20722136
complex = Complex(real || 0, imaginary)
20732137
complex_real = complex.real
20742138
complex = complex.to_i if complex.imaginary.zero? && complex_real == complex_real.to_i
@@ -2089,17 +2153,19 @@ def parse(passed_unit_string = "0")
20892153
else
20902154
(proper + fraction)
20912155
end
2092-
rational_as_int = rational.to_int
2093-
rational = rational_as_int if rational_as_int == rational
2156+
rational = unit_class.normalize_to_i(rational)
20942157
return copy(unit_class.new(unit_s || 1) * rational)
20952158
end
20962159

20972160
match = unit_string.match(NUMBER_REGEX)
20982161
unit_str, scalar_str = match.values_at(:unit, :scalar)
20992162
unit = unit_class.cached.get(unit_str)
2100-
mult = scalar_str == "" ? 1.0 : scalar_str.to_f
2101-
mult_as_int = mult.to_int
2102-
mult = mult_as_int if mult_as_int == mult
2163+
mult = if scalar_str == "" || scalar_str.nil?
2164+
unit_class.parse_number("1")
2165+
else
2166+
unit_class.parse_number(scalar_str)
2167+
end
2168+
mult = unit_class.normalize_to_i(mult)
21032169

21042170
if unit
21052171
copy(unit)
@@ -2184,17 +2250,16 @@ def parse(passed_unit_string = "0")
21842250
bottom_scalar, bottom = bottom.scan(NUMBER_UNIT_REGEX)[0]
21852251
end
21862252

2187-
@scalar = @scalar.to_f unless !@scalar || @scalar.empty?
2253+
@scalar = unit_class.parse_number(@scalar) if @scalar && !@scalar.empty?
21882254
@scalar = 1 unless @scalar.is_a? Numeric
2189-
scalar_as_int = @scalar.to_int
2190-
@scalar = scalar_as_int if scalar_as_int == @scalar
2255+
@scalar = unit_class.normalize_to_i(@scalar)
21912256

2192-
bottom_scalar = 1 if !bottom_scalar || bottom_scalar.empty?
2193-
bottom_scalar_as_int = bottom_scalar.to_i
2194-
bottom_scalar = if bottom_scalar_as_int == bottom_scalar
2195-
bottom_scalar_as_int
2257+
bottom_scalar = if !bottom_scalar || bottom_scalar.empty?
2258+
1
2259+
elsif bottom_scalar.match?(/^#{INTEGER_DIGITS_REGEX}$/)
2260+
Integer(bottom_scalar)
21962261
else
2197-
bottom_scalar.to_f
2262+
unit_class.normalize_to_i(unit_class.parse_number(bottom_scalar))
21982263
end
21992264

22002265
@scalar /= bottom_scalar
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe "Parsing with BigDecimal enabled" do
6+
around do |example|
7+
RubyUnits.reset
8+
RubyUnits.configure do |config|
9+
config.use_bigdecimal = true
10+
end
11+
example.run
12+
RubyUnits.reset
13+
end
14+
15+
it "parses decimal strings into BigDecimal" do
16+
u = RubyUnits::Unit.new("0.1 m")
17+
expect(u.scalar).to be_a(BigDecimal)
18+
expect(u.scalar).to eq(BigDecimal("0.1"))
19+
end
20+
21+
it "converts integral BigDecimal to Integer when appropriate" do
22+
expect(RubyUnits::Unit.new("1.0").scalar).to be(1)
23+
end
24+
25+
it "parses scientific notation into BigDecimal" do
26+
u = RubyUnits::Unit.new("1e-1 m")
27+
expect(u.scalar).to be_a(BigDecimal)
28+
expect(u.scalar).to eq(BigDecimal("0.1"))
29+
end
30+
31+
it "parses plain integers as Integer" do
32+
expect(RubyUnits::Unit.new("1 m").scalar).to be(Integer(1))
33+
end
34+
35+
it "parses plain floats as BigDecimal" do
36+
u = RubyUnits::Unit.new("3.5 g")
37+
expect(u.scalar).to be_a(BigDecimal)
38+
expect(u.convert_to("mg").scalar).to eq(BigDecimal("3500"))
39+
end
40+
end

0 commit comments

Comments
 (0)