Skip to content
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ Metrics/AbcSize:
Enabled: false
Layout/ExtraSpacing:
AllowForAlignment: false
RSpec/DescribeClass:
Enabled: false
19 changes: 2 additions & 17 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2025-12-28 23:46:28 UTC using RuboCop version 1.82.1.
# on 2025-12-30 15:16:10 UTC using RuboCop version 1.82.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
Expand Down Expand Up @@ -42,21 +42,6 @@ RSpec/ContextWording:
- 'spec/ruby_units/unit_spec.rb'
- 'spec/ruby_units/utf-8/unit_spec.rb'

# Offense count: 13
# Configuration parameters: IgnoredMetadata.
RSpec/DescribeClass:
Exclude:
- '**/spec/features/**/*'
- '**/spec/requests/**/*'
- '**/spec/routing/**/*'
- '**/spec/system/**/*'
- '**/spec/views/**/*'
- 'spec/ruby_units/bugs_spec.rb'
- 'spec/ruby_units/definition_spec.rb'
- 'spec/ruby_units/initialization_spec.rb'
- 'spec/ruby_units/temperature_spec.rb'
- 'spec/ruby_units/unit_spec.rb'

# Offense count: 1
RSpec/DescribeMethod:
Exclude:
Expand Down Expand Up @@ -93,7 +78,7 @@ RSpec/MultipleDescribes:
Exclude:
- 'spec/ruby_units/unit_spec.rb'

# Offense count: 30
# Offense count: 33
RSpec/MultipleExpectations:
Max: 6

Expand Down
86 changes: 81 additions & 5 deletions lib/ruby_units/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# It allows for the creation, conversion, and mathematical operations on physical quantities
# with associated units of measurement.
module RubyUnits
# Raised when a requested feature cannot be enabled because a runtime
# dependency has not been loaded by the caller.
class MissingDependencyError < StandardError; end
class << self
# Get or initialize the configuration
# @return [Configuration] the configuration instance
Expand Down Expand Up @@ -88,16 +91,66 @@ class Configuration
# @return [Numeric] the precision to use when converting to a rational (default: 0.0001)
attr_reader :default_precision

# Initialize configuration with keyword arguments
# Whether to parse numeric literals as BigDecimal when parsing unit strings.
# This is an opt-in feature because BigDecimal has different performance
# and precision characteristics compared to Float. The default is `false`.
#
# When enabled, numeric strings parsed from unit inputs will be converted
# to BigDecimal. The caller must require the BigDecimal library before
# enabling this mode (for example `require 'bigdecimal'`).
#
# @!attribute [rw] use_bigdecimal
# @return [Boolean] whether to coerce numeric literals to BigDecimal (default: false)
attr_reader :use_bigdecimal

# Initialize configuration with keyword arguments.
#
# Accepts keyword options to set initial configuration values. Each value
# is validated by the corresponding setter method; invalid values will
# raise an error (see @raise tags below). Boolean values for
# `separator` are accepted for backward compatibility but will emit a
# deprecation warning.
#
# @param opts [Hash] the keyword options hash
# @option opts [Symbol, Boolean] :separator One of `:space` or `:none`.
# Boolean `true`/`false` are accepted for backward compatibility
# (`true` -> `:space`, `false` -> `:none`) and will emit a deprecation
# warning. Internally a `:space` separator is stored as a single space
# string (" ") and `:none` is stored as `nil`. Default: `:space`.
# @option opts [Symbol] :format The output format, one of `:rational` or
# `:exponential`. Default: `:rational`.
# @option opts [Numeric] :default_precision Positive numeric precision
# used when rationalizing fractional values. Default: `0.0001`.
# @option opts [Boolean] :use_bigdecimal When `true`, numeric literals
# parsed from unit input strings will be coerced to `BigDecimal`.
# The caller must require the BigDecimal library before enabling this
# option. Default: `false`.
#
# @raise [ArgumentError] If any provided value fails validation (invalid
# `separator`, invalid `format`, non-positive `default_precision`, or
# non-boolean `use_bigdecimal`).
# @raise [MissingDependencyError] If `use_bigdecimal` is enabled but the
# `BigDecimal` library has not been required.
#
# @example
# Configuration.new(
# separator: :none,
# format: :exponential,
# default_precision: 1e-6,
# use_bigdecimal: false
# )
#
# @param separator [Symbol, Boolean] the separator to use (:space or :none, true/false for backward compatibility) (default: :space)
# @param format [Symbol] the format to use when generating output (:rational or :exponential) (default: :rational)
# @param default_precision [Numeric] the precision to use when converting to a rational (default: 0.0001)
# @return [Configuration] a new configuration instance
def initialize(separator: :space, format: :rational, default_precision: 0.0001)
def initialize(**opts)
separator = opts.fetch(:separator, :space)
format = opts.fetch(:format, :rational)
default_precision = opts.fetch(:default_precision, 0.0001)
use_bigdecimal = opts.fetch(:use_bigdecimal, false)

self.separator = separator
self.format = format
self.default_precision = default_precision
self.use_bigdecimal = use_bigdecimal
end

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

@default_precision = value
end

# Enable or disable BigDecimal parsing for numeric literals.
#
# To enable BigDecimal parsing, the BigDecimal library must already be
# required by the application. If you attempt to enable this option
# without requiring BigDecimal first a `MissingDependencyError` will be
# raised to make the dependency requirement explicit.
#
# @param value [Boolean]
# @return [void]
# @raise [ArgumentError] if `value` is not a boolean
# @raise [MissingDependencyError] when enabling without requiring BigDecimal first
# @example
# require 'bigdecimal'
# require 'bigdecimal/util' # for to_d method (optional)
# RubyUnits.configuration.use_bigdecimal = true
def use_bigdecimal=(value)
raise ArgumentError, "configuration 'use_bigdecimal' must be a boolean" unless [true, false].include?(value)

raise MissingDependencyError, "To enable use_bigdecimal, require 'bigdecimal' before setting RubyUnits.configuration.use_bigdecimal = true" if value && !defined?(BigDecimal)

@use_bigdecimal = value
end
end
end
6 changes: 3 additions & 3 deletions lib/ruby_units/math.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def atan2(x, y)
# :reek:UncommunicativeMethodName
def log10(number)
if number.is_a?(RubyUnits::Unit)
super(number.to_f)
super(number.scalar)
else
super
end
Expand All @@ -251,10 +251,10 @@ def log10(number)
# @example
# Math.log(Unit.new("2.718")) #=> ~1.0 (natural log)
# Math.log(Unit.new("8"), 2) #=> 3.0 (log base 2)
# Math.log(Math::E) #=> 1.0
# Math.log(Math::E) #=> 1.0
def log(number, base = ::Math::E)
if number.is_a?(RubyUnits::Unit)
super(number.to_f, base)
super(number.scalar, base)
else
super
end
Expand Down
113 changes: 89 additions & 24 deletions lib/ruby_units/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,64 @@ def self.base_units
@base_units ||= definitions.dup.select { |_, definition| definition.base? }.keys.map { new(_1) }
end

# Coerce a string or numeric value into the configured numeric type.
#
# When `RubyUnits.configuration.use_bigdecimal` is true, numeric strings are
# converted to BigDecimal (the caller must require 'bigdecimal').
# Otherwise numeric strings are converted to Float. If the input is already
# a Numeric it is returned unchanged.
#
# @param value [String, Numeric] the value to coerce
# @return [Numeric] a Numeric instance (BigDecimal or Float) or the original Numeric
# @raise [ArgumentError] if the value cannot be coerced by the underlying constructors
# @example
# Unit.parse_number("3.14") #=> 3.14 (Float) unless use_bigdecimal is enabled
# Unit.parse_number(2) #=> 2 (unchanged)
def self.parse_number(value)
return value if value.is_a?(Numeric)

if RubyUnits.configuration.use_bigdecimal
BigDecimal(value)
else
Float(value)
end
end

# Return an Integer when the provided numeric value is mathematically
# integral; otherwise return the original numeric value.
#
# The method first prefers `to_int` when available (exact integer
# conversion). If not available it falls back to `to_i` and compares the
# converted integer to the original value. This works for Float, Rational, Complex,
# BigDecimal (if loaded), and Integer.
#
# @param value [Numeric] the numeric value to normalize
# @return [Integer, Numeric] an `Integer` when the value is integral, otherwise the original numeric
# @example
# Unit.normalize_to_i(2.0) #=> 2
# Unit.normalize_to_i(Rational(3,1)) #=> 3
# Unit.normalize_to_i(3.5) #=> 3.5
# :reek:ManualDispatch
def self.normalize_to_i(value)
return value unless value.is_a?(Numeric)

responds_to_int = value.respond_to?(:to_int)
if responds_to_int || value.respond_to?(:to_i)
int = if responds_to_int
value.to_int
else
value.to_i
end
int == value ? int : value
else
value
end
rescue RangeError
# This can happen when a Complex number with a non-zero imaginary part is provided, or when value is Float::NAN or
# Float::INFINITY
value
end

# Parse a string consisting of a number and a unit string
# NOTE: This does not properly handle units formatted like '12mg/6ml'
#
Expand All @@ -395,7 +453,7 @@ def self.parse_into_numbers_and_units(string)
fractional_part = Rational(Regexp.last_match(3).to_i, Regexp.last_match(4).to_i)
sign * (whole_part + fractional_part)
else
num.to_f
parse_number(num)
end,
unit.to_s.strip
]
Expand Down Expand Up @@ -1239,10 +1297,7 @@ def convert_to(other)
converted_value = conversion_scalar * (source_numerator_values + target_denominator_values).reduce(1, :*) / (target_numerator_values + source_denominator_values).reduce(1, :*)
# Convert the scalar to an Integer if the result is equivalent to an
# integer
if scalar_is_integer
converted_as_int = converted_value.to_i
converted_value = converted_as_int if converted_as_int == converted_value
end
converted_value = unit_class.normalize_to_i(converted_value)
unit_class.new(scalar: converted_value, numerator: target_num, denominator: target_den, signature: target.signature)
end
end
Expand All @@ -1257,6 +1312,17 @@ def to_f
return_scalar_or_raise(:to_f, Float)
end

# Convert the unit's scalar to BigDecimal. Raises if not unitless.
#
# Note: Using this method requires the BigDecimal class to be available
# (e.g., by requiring `'bigdecimal'` and `'bigdecimal/util'`).
#
# @return [BigDecimal]
# @raise [RuntimeError] when not unitless
def to_d
return_scalar_or_raise(:to_d, BigDecimal)
end

# converts the unit back to a complex if it is unitless. Otherwise raises an exception
# @return [Complex]
# @raise [RuntimeError] when not unitless
Expand Down Expand Up @@ -2063,12 +2129,10 @@ def parse(passed_unit_string = "0")
if unit_string.start_with?(COMPLEX_NUMBER)
match = unit_string.match(COMPLEX_REGEX)
real_str, imaginary_str, unit_s = match.values_at(:real, :imaginary, :unit)
real = Float(real_str) if real_str
imaginary = Float(imaginary_str)
real_as_int = real.to_i if real
real = real_as_int if real_as_int == real
imaginary_as_int = imaginary.to_i
imaginary = imaginary_as_int if imaginary_as_int == imaginary
real = unit_class.parse_number(real_str) if real_str
imaginary = unit_class.parse_number(imaginary_str)
real = unit_class.normalize_to_i(real) if real
imaginary = unit_class.normalize_to_i(imaginary)
complex = Complex(real || 0, imaginary)
complex_real = complex.real
complex = complex.to_i if complex.imaginary.zero? && complex_real == complex_real.to_i
Expand All @@ -2089,17 +2153,19 @@ def parse(passed_unit_string = "0")
else
(proper + fraction)
end
rational_as_int = rational.to_int
rational = rational_as_int if rational_as_int == rational
rational = unit_class.normalize_to_i(rational)
return copy(unit_class.new(unit_s || 1) * rational)
end

match = unit_string.match(NUMBER_REGEX)
unit_str, scalar_str = match.values_at(:unit, :scalar)
unit = unit_class.cached.get(unit_str)
mult = scalar_str == "" ? 1.0 : scalar_str.to_f
mult_as_int = mult.to_int
mult = mult_as_int if mult_as_int == mult
mult = if scalar_str == "" || scalar_str.nil?
unit_class.parse_number("1")
else
unit_class.parse_number(scalar_str)
end
mult = unit_class.normalize_to_i(mult)

if unit
copy(unit)
Expand Down Expand Up @@ -2184,17 +2250,16 @@ def parse(passed_unit_string = "0")
bottom_scalar, bottom = bottom.scan(NUMBER_UNIT_REGEX)[0]
end

@scalar = @scalar.to_f unless !@scalar || @scalar.empty?
@scalar = unit_class.parse_number(@scalar) if @scalar && !@scalar.empty?
@scalar = 1 unless @scalar.is_a? Numeric
scalar_as_int = @scalar.to_int
@scalar = scalar_as_int if scalar_as_int == @scalar
@scalar = unit_class.normalize_to_i(@scalar)

bottom_scalar = 1 if !bottom_scalar || bottom_scalar.empty?
bottom_scalar_as_int = bottom_scalar.to_i
bottom_scalar = if bottom_scalar_as_int == bottom_scalar
bottom_scalar_as_int
bottom_scalar = if !bottom_scalar || bottom_scalar.empty?
1
elsif bottom_scalar.match?(/^#{INTEGER_DIGITS_REGEX}$/)
Integer(bottom_scalar)
else
bottom_scalar.to_f
unit_class.normalize_to_i(unit_class.parse_number(bottom_scalar))
end

@scalar /= bottom_scalar
Expand Down
40 changes: 40 additions & 0 deletions spec/ruby_units/bigdecimal_parsing_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe "Parsing with BigDecimal enabled" do
around do |example|
RubyUnits.reset
RubyUnits.configure do |config|
config.use_bigdecimal = true
end
example.run
RubyUnits.reset
end

it "parses decimal strings into BigDecimal" do
u = RubyUnits::Unit.new("0.1 m")
expect(u.scalar).to be_a(BigDecimal)
expect(u.scalar).to eq(BigDecimal("0.1"))
end

it "converts integral BigDecimal to Integer when appropriate" do
expect(RubyUnits::Unit.new("1.0").scalar).to be(1)
end

it "parses scientific notation into BigDecimal" do
u = RubyUnits::Unit.new("1e-1 m")
expect(u.scalar).to be_a(BigDecimal)
expect(u.scalar).to eq(BigDecimal("0.1"))
end

it "parses plain integers as Integer" do
expect(RubyUnits::Unit.new("1 m").scalar).to be(Integer(1))
end

it "parses plain floats as BigDecimal" do
u = RubyUnits::Unit.new("3.5 g")
expect(u.scalar).to be_a(BigDecimal)
expect(u.convert_to("mg").scalar).to eq(BigDecimal("3500"))
end
end
Loading