Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
16 changes: 8 additions & 8 deletions ruby-units.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require "ruby_units/version"

Gem::Specification.new do |spec|
spec.name = "ruby-units"
spec.version = RubyUnits::Unit::VERSION
spec.authors = ["Kevin Olbrich"]
spec.email = ["kevin.olbrich@gmail.com"]
spec.name = "ruby-units"
spec.version = RubyUnits::Unit::VERSION
spec.authors = ["Kevin Olbrich"]
spec.email = ["kevin.olbrich@gmail.com"]

spec.required_rubygems_version = ">= 2.0"
spec.required_ruby_version = ">= 3.2"
spec.summary = "Provides classes and methods to perform unit math and conversions"
spec.description = "Provides classes and methods to perform unit math and conversions"
spec.homepage = "https://github.com/olbrich/ruby-units"
spec.license = "MIT"
spec.summary = "Provides classes and methods to perform unit math and conversions"
spec.description = "Provides classes and methods to perform unit math and conversions"
spec.homepage = "https://github.com/olbrich/ruby-units"
spec.license = "MIT"

spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/olbrich/ruby-units"
Expand Down
Loading