From 550bd55a3d6d926349d6408c496c38290e99dd5b Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 1 Jul 2026 23:08:48 +0500 Subject: [PATCH 01/18] Add programmatic composite unit builder Compose units from parts instead of only parsing strings. Adds the multiplication and division operator DSL (on Unit, Dimension and Formula via a Composable mixin) and the Unitsml.compose keyword form, both producing a Formula whose output matches the equivalent parsed string across every format. Units are referenced by symbol id or short name, powers come from the constructor, and quantity/name/multiplier metadata are passed to the render methods. Also resolves quantities by name and short slug (Quantities#find_by_name) and emits a canonical Quantity element with the NIST xml:id, quantityType and dimensionURL. Implements #2. --- docs/README.adoc | 50 +++++- lib/unitsml.rb | 7 + lib/unitsml/composable.rb | 25 +++ lib/unitsml/composite.rb | 59 ++++++ lib/unitsml/dimension.rb | 1 + lib/unitsml/errors.rb | 3 + lib/unitsml/errors/mixed_terms_error.rb | 15 ++ lib/unitsml/errors/unknown_prefix_error.rb | 15 ++ lib/unitsml/errors/unknown_unit_error.rb | 16 ++ lib/unitsml/formula.rb | 152 +++++++++++++++- lib/unitsml/number.rb | 25 +++ lib/unitsml/opal.rb | 5 + lib/unitsml/unit.rb | 39 +++- lib/unitsml/unitsdb/quantities.rb | 20 +++ lib/unitsml/unitsdb/units.rb | 10 ++ lib/unitsml/utility.rb | 27 ++- spec/unitsml/composite_spec.rb | 200 +++++++++++++++++++++ spec/unitsml/utility_quantity_spec.rb | 82 +++++++++ 18 files changed, 736 insertions(+), 15 deletions(-) create mode 100644 lib/unitsml/composable.rb create mode 100644 lib/unitsml/composite.rb create mode 100644 lib/unitsml/errors/mixed_terms_error.rb create mode 100644 lib/unitsml/errors/unknown_prefix_error.rb create mode 100644 lib/unitsml/errors/unknown_unit_error.rb create mode 100644 spec/unitsml/composite_spec.rb create mode 100644 spec/unitsml/utility_quantity_spec.rb diff --git a/docs/README.adoc b/docs/README.adoc index d69b7e4..ae47cfd 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -149,13 +149,61 @@ Prefixed units: mm, kg, μm A composite unit is a unit that is derived from two or more basic units conjoined by multiplication or division. -Composite units are created using the `Unitsml.parse` method. +Composite units are created using the `Unitsml.parse` method, or built +programmatically (see below). Compound units with operations: kg*s^-2 Units with powers: C^3 Square roots: sqrt(Hz) Units with prefixes: m/s, m^2/s^2 +==== Programmatic composition + +When the parts are already available as data rather than a string, composite +units can be built without parsing. + +Multiplication (`*`) and division (`/`) compose units, dimensions and formulas, +returning a `Unitsml::Formula`: + +[source,ruby] +---- +# watt per metre per steradian; "/" inverts the right-hand operand +Unitsml::Unit.new("W") / Unitsml::Unit.new("m") / Unitsml::Unit.new("sr") + +# m·s⁻²; powers come from the constructor's second argument +Unitsml::Unit.new("m") / Unitsml::Unit.new("s", 2) + +# prefixes via the `prefix:` keyword +Unitsml::Unit.new("m", prefix: "k") # km +---- + +A unit is referenced by its symbol id (as in a parsed string, e.g. `"W"`) or by +its `short` name (e.g. `"watt"`). An unknown unit, prefix or a units/dimensions +mix raises a `Unitsml::Errors::*` error. + +The `Unitsml.compose` keyword form builds the same `Formula` from a list, where +each entry is a symbol id, a `short` name, a `{ unit:, power:, prefix: }` hash, +or a pre-built `Unitsml::Unit`: + +[source,ruby] +---- +Unitsml.compose( + units: ["W", { unit: "m", power: -1 }, { unit: "sr", power: -1 }], + quantity: "radiance", +) +---- + +The `quantity`, `name` and `multiplier` metadata are also accepted directly by +the format methods. They apply to `to_xml` (`quantity`/`name` are ignored by the +other formats), and a value passed at render time overrides one carried by a +parsed comma-metadata expression: + +[source,ruby] +---- +formula = Unitsml.compose(units: ["W", { unit: "m", power: -1 }]) +formula.to_xml(quantity: "radiance") # emits a element +---- + === Format representation diff --git a/lib/unitsml.rb b/lib/unitsml.rb index b71d12d..63927ac 100644 --- a/lib/unitsml.rb +++ b/lib/unitsml.rb @@ -6,6 +6,8 @@ module Unitsml module_function + autoload :Composable, "unitsml/composable" + autoload :Composite, "unitsml/composite" autoload :Dimension, "unitsml/dimension" autoload :Configuration, "unitsml/configuration" autoload :Errors, "unitsml/errors" @@ -33,4 +35,9 @@ module Unitsml def parse(string) Unitsml::Parser.new(string).parse end + + def compose(units:, quantity: nil, name: nil, multiplier: nil) + Unitsml::Composite.new(units: units, quantity: quantity, name: name, + multiplier: multiplier).to_formula + end end diff --git a/lib/unitsml/composable.rb b/lib/unitsml/composable.rb new file mode 100644 index 0000000..9f45760 --- /dev/null +++ b/lib/unitsml/composable.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Unitsml + # Multiplicative composition for units, dimensions and formulas. `*` combines + # two operands; `/` combines with the right-hand side inverted. Both are + # non-mutating and return a fresh root Formula (the real assembly lives in + # Formula.build_product). Included by Unit, Dimension and Formula only. + module Composable + def *(other) + Formula.build_product(composable_terms, other, "*") + end + + def /(other) + Formula.build_product(composable_terms, other, "/") + end + + protected + + # A leaf (Unit/Dimension) contributes itself; Formula overrides this to + # contribute its already-interleaved term list. + def composable_terms + [self] + end + end +end diff --git a/lib/unitsml/composite.rb b/lib/unitsml/composite.rb new file mode 100644 index 0000000..c3e5b82 --- /dev/null +++ b/lib/unitsml/composite.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Unitsml + # Programmatic keyword builder behind Unitsml.compose. It is a helper, NOT a + # Formula subclass: #to_formula materialises the composed root Formula, so the + # rendered object's class stays Formula. Entries mirror what the parser + # accepts - units or dimensions, never a mix (Formula.from_terms enforces it). + class Composite + def initialize(units:, quantity: nil, name: nil, multiplier: nil) + @units = units.is_a?(Hash) ? [units] : Array(units) + @quantity = quantity + @name = name + @multiplier = multiplier + end + + def to_formula + Formula.from_terms(@units.map { |entry| coerce_entry(entry) }, + quantity: @quantity, name: @name, + multiplier: @multiplier) + end + + private + + def coerce_entry(entry) + case entry + when Unit, Dimension then entry + when Hash then build_unit(entry) + when String, Symbol then build_reference(entry.to_s) + when nil then validate_reference(nil) + else raise ArgumentError, "invalid unit entry: #{entry.inspect}" + end + end + + def build_unit(entry) + Unit.new(validate_reference(entry[:unit]), entry[:power], + prefix: entry[:prefix]) + end + + # A dim_* reference builds a Dimension (as the parser would); anything else + # resolves as a unit and fails fast on an unknown reference. + def build_reference(reference) + reference = validate_reference(reference) + return Dimension.new(reference) if dimension_reference?(reference) + + Unit.new(reference) + end + + def validate_reference(reference) + string = reference.to_s + return string unless string.strip.empty? + + raise Errors::UnknownUnitError.new(value: reference) + end + + def dimension_reference?(reference) + Unitsdb.dimensions.parsables.key?(reference) + end + end +end diff --git a/lib/unitsml/dimension.rb b/lib/unitsml/dimension.rb index 47b508f..5537f5c 100644 --- a/lib/unitsml/dimension.rb +++ b/lib/unitsml/dimension.rb @@ -3,6 +3,7 @@ module Unitsml class Dimension include MathmlHelper + include Composable attr_accessor :dimension_name, :power_numerator diff --git a/lib/unitsml/errors.rb b/lib/unitsml/errors.rb index 33988a7..ed9d1ae 100644 --- a/lib/unitsml/errors.rb +++ b/lib/unitsml/errors.rb @@ -4,9 +4,12 @@ module Unitsml module Errors autoload :BaseError, "unitsml/errors/base_error" autoload :InvalidModelError, "unitsml/errors/invalid_model_error" + autoload :MixedTermsError, "unitsml/errors/mixed_terms_error" autoload :OpalPayloadNotBundledError, "unitsml/errors/opal_payload_not_bundled_error" autoload :PlurimathLoadError, "unitsml/errors/plurimath_load_error" + autoload :UnknownPrefixError, "unitsml/errors/unknown_prefix_error" + autoload :UnknownUnitError, "unitsml/errors/unknown_unit_error" autoload :UnsupportedPayloadTypeError, "unitsml/errors/unsupported_payload_type_error" end diff --git a/lib/unitsml/errors/mixed_terms_error.rb b/lib/unitsml/errors/mixed_terms_error.rb new file mode 100644 index 0000000..6184d89 --- /dev/null +++ b/lib/unitsml/errors/mixed_terms_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # Raised when a composition mixes units and dimensions in one expression. + # The XML serializer emits only the dimension block for such a mix, silently + # dropping the units; the parser rejects the same input outright. + class MixedTermsError < Unitsml::Errors::BaseError + def initialize(msg = nil) + super(msg || "[unitsml] Cannot combine units and dimensions in a " \ + "single expression.") + end + end + end +end diff --git a/lib/unitsml/errors/unknown_prefix_error.rb b/lib/unitsml/errors/unknown_prefix_error.rb new file mode 100644 index 0000000..b82371b --- /dev/null +++ b/lib/unitsml/errors/unknown_prefix_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + class UnknownPrefixError < Unitsml::Errors::BaseError + attr_reader :value, :field + + def initialize(value:, field: :prefix) + @value = value + @field = field + super("[unitsml] Unknown prefix reference: #{value.inspect}.") + end + end + end +end diff --git a/lib/unitsml/errors/unknown_unit_error.rb b/lib/unitsml/errors/unknown_unit_error.rb new file mode 100644 index 0000000..c1e3149 --- /dev/null +++ b/lib/unitsml/errors/unknown_unit_error.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + class UnknownUnitError < Unitsml::Errors::BaseError + attr_reader :value, :field + + def initialize(value:, field: :unit) + @value = value + @field = field + super("[unitsml] Unknown unit reference: #{value.inspect} — expected " \ + "a symbol id (e.g. \"W\") or short name (e.g. \"watt\").") + end + end + end +end diff --git a/lib/unitsml/formula.rb b/lib/unitsml/formula.rb index 15bc9e1..fdf0fec 100644 --- a/lib/unitsml/formula.rb +++ b/lib/unitsml/formula.rb @@ -6,6 +6,7 @@ module Unitsml class Formula include MathmlHelper + include Composable attr_accessor :value, :explicit_value, :root @@ -89,8 +90,150 @@ def dimensions_extraction extract_dimensions(value) end + class << self + # Assemble a root Formula from a left-hand term list and a right-hand + # operand. `/` inverts every unit/dimension term on the right. Terms are + # deep-copied so operands are never mutated. + def build_product(lhs_terms, rhs, operator) + lhs = lhs_terms.map { |term| dup_term(term) } + rhs_terms = terms_of(rhs).map { |term| dup_term(term) } + invert_terms(rhs_terms) if operator == "/" + terms = lhs + [Extender.new("*")] + rhs_terms + guard_terms!(terms) + text = synthesize_text(terms) + new(terms, root: true, orig_text: text, norm_text: text) + end + + # Build a root Formula from an ordered list of unit/dimension objects, + # interleaving multiplication. Used by the keyword compose builder. + def from_terms(objects, quantity: nil, name: nil, multiplier: nil) + raise ArgumentError, "compose requires at least one unit" if objects.empty? + + terms = interleave(objects.map { |object| dup_term(object) }) + guard_terms!(terms) + text = synthesize_text(terms) + new(terms, explicit_value: build_extras(quantity, name, multiplier), + root: true, orig_text: text, norm_text: text) + end + + private + + def interleave(objects) + objects.each_with_index.flat_map do |object, index| + index.zero? ? [object] : [Extender.new("*"), object] + end + end + + def build_extras(quantity, name, multiplier) + extras = { quantity: quantity, name: name, + multiplier: multiplier }.compact + extras.empty? ? nil : extras + end + + def terms_of(rhs) + case rhs + when Formula then rhs.value + when Unit, Dimension then [rhs] + else raise ArgumentError, "cannot compose with #{rhs.inspect}" + end + end + + # Deep-copy a term so composing never mutates an operand. Nested Formula, + # Fenced and Sqrt structures are copied recursively (a parsed operand can + # carry them), and unit/dimension powers are coerced + duplicated. + def dup_term(term) + case term + when Unit then dup_leaf(term, dup_prefix: true) + when Dimension then dup_leaf(term) + when Formula then dup_formula(term) + when Fenced then Fenced.new(term.open_paren, dup_term(term.value), + term.close_paren) + when Sqrt then Sqrt.new(dup_term(term.value)) + else term.dup + end + end + + def dup_leaf(term, dup_prefix: false) + copy = term.dup + power = Number.coerce(copy.power_numerator) + copy.power_numerator = power.is_a?(Number) ? power.dup : power + copy.prefix = copy.prefix.dup if dup_prefix && copy.prefix + copy + end + + def dup_formula(formula) + copy = formula.dup + copy.value = formula.value.map { |term| dup_term(term) } + copy + end + + # Invert every unit/dimension term (division). The right-hand term list is + # already exponent-resolved - the parser bakes division into the powers and + # leaves "/" extenders decorative - so each leaf is negated and extenders + # are ignored; Fenced/Sqrt/nested Formula structures recurse. + def invert_terms(terms) + terms.each do |term| + case term + when Unit then invert_unit(term) + when Dimension then invert_dimension(term) + when Fenced, Sqrt then invert_terms([term.value]) + when Formula then invert_terms(term.value) + end + end + end + + # A resolved exponent of +1 renders as no exponent (canonical plain unit), + # so dividing by an inverse term matches the parsed equivalent. + def invert_unit(unit) + unit.inverse_power_numerator + unit.power_numerator = nil if unit.power_numerator&.raw_value == "1" + end + + def invert_dimension(dimension) + raw = dimension.power_numerator&.raw_value + negated = raw ? negate(raw) : "-1" + dimension.power_numerator = negated == "1" ? nil : Number.new(negated) + end + + def negate(raw) + raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}" + end + + def guard_terms!(terms) + raise Errors::MixedTermsError if terms.any?(Unit) && + terms.any?(Dimension) + end + + def synthesize_text(terms) + terms.map { |term| term_text(term) }.join + end + + def term_text(term) + case term + when Unit then term.xml_postprocess_name + when Dimension then dimension_text(term) + when Extender then term.symbol + when Fenced + "#{term.open_paren}#{term_text(term.value)}#{term.close_paren}" + when Sqrt then "sqrt(#{term_text(term.value)})" + when Formula then synthesize_text(term.value) + else "" + end + end + + def dimension_text(dimension) + exponent = dimension.power_numerator&.raw_value + suffix = exponent && exponent != "1" ? "^#{exponent}" : "" + "#{dimension.dimension_name}#{suffix}" + end + end + private + def composable_terms + value + end + def extract_dimensions(formula) formula.each_with_object([]) do |term, dimensions| case term @@ -135,10 +278,12 @@ def units(options) dims = Utility.units2dimensions(extract_units(value)) [ Utility.unit(all_units, self, dims, norm_text, - explicit_value&.dig(:name), options), + options[:name] || explicit_value&.dig(:name), options), Utility.prefixes(all_units, options), *unique_dimensions(dims, norm_text), - Utility.quantity(norm_text, explicit_value&.dig(:quantity)), + Utility.quantity(norm_text, + options[:quantity] || explicit_value&.dig(:quantity), + dims), ].join end @@ -173,7 +318,8 @@ def prefixes(options) [ Utility.prefixes([prefix_object], options), Utility.dimension(norm_text), - Utility.quantity(norm_text, explicit_value&.dig(:quantity)), + Utility.quantity(norm_text, + options[:quantity] || explicit_value&.dig(:quantity)), ].join end diff --git a/lib/unitsml/number.rb b/lib/unitsml/number.rb index 608abed..4d6b847 100644 --- a/lib/unitsml/number.rb +++ b/lib/unitsml/number.rb @@ -12,6 +12,31 @@ def initialize(value) @value = value end + # Coerce a builder-supplied power into a Number, mirroring the exponent + # strings the parser produces: 1 -> "1", -1 -> "-1", 1/2 -> "1/2", + # 2.0 -> "2". Non-integer Floats are rejected (the parser cannot express a + # decimal exponent) - pass a Rational instead. nil and existing power + # objects pass through untouched. + def self.coerce(power) + case power + when nil, Number then power + when Integer then new(power.to_s) + when Rational + new(power.denominator == 1 ? power.numerator.to_s : power.to_s) + when Float then coerce_float(power) + else raise ArgumentError, "unsupported power: #{power.inspect}" + end + end + + def self.coerce_float(power) + unless power.finite? && power == power.to_i + raise ArgumentError, "non-integer Float power #{power.inspect}; " \ + "use a Rational (e.g. Rational(1, 2))" + end + + new(power.to_i.to_s) + end + def ==(other) self.class == other.class && value == other&.value diff --git a/lib/unitsml/opal.rb b/lib/unitsml/opal.rb index eee4d07..bf2f8aa 100644 --- a/lib/unitsml/opal.rb +++ b/lib/unitsml/opal.rb @@ -34,8 +34,11 @@ require "unitsml/errors" require "unitsml/errors/base_error" require "unitsml/errors/invalid_model_error" +require "unitsml/errors/mixed_terms_error" require "unitsml/errors/opal_payload_not_bundled_error" require "unitsml/errors/plurimath_load_error" +require "unitsml/errors/unknown_prefix_error" +require "unitsml/errors/unknown_unit_error" require "unitsml/errors/unsupported_payload_type_error" require "unitsml/opal/database_payload" @@ -82,6 +85,8 @@ require "unitsml/model/units/symbol" require "unitsml/model/units/system" +require "unitsml/composable" +require "unitsml/composite" require "unitsml/dimension" require "unitsml/extender" require "unitsml/fenced" diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index fd65b22..6c49102 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -3,6 +3,7 @@ module Unitsml class Unit include MathmlHelper + include Composable attr_accessor :unit_name, :power_numerator, :prefix @@ -12,8 +13,8 @@ class Unit def initialize(unit_name, power_numerator = nil, prefix: nil) - @prefix = prefix - @unit_name = unit_name + @prefix = coerce_prefix(prefix) + @unit_name = resolve_ref(unit_name) @power_numerator = power_numerator end @@ -122,6 +123,40 @@ def inverse_power_numerator private + # Resolve a unit reference to a canonical symbol id. Tries the symbol id + # first (parse-consistent), then the unique `short` slug. The empty string + # and the UNKNOWN sentinel are passed through untouched (internal callers + # rely on them). Raises for anything unresolvable. + def resolve_ref(ref) + ref = ref.to_s + return ref if ref.empty? || ref == Utility::UNKNOWN + return ref if Unitsdb.units.find_by_symbol_id(ref) + + short_unit = Unitsdb.units.find_by_short(ref) + return short_unit.symbols.first.id if short_unit + + raise Errors::UnknownUnitError.new(value: ref) + end + + # A pre-built Prefix (the parse path) is passed through untouched, so the + # parser keeps its lazy resolution. A string/symbol prefix (the builder) is + # validated eagerly and wrapped. + def coerce_prefix(prefix) + return prefix if prefix.nil? || prefix.is_a?(Prefix) + + name = prefix.to_s + # A blank prefix means "no prefix", and the UNKNOWN sentinel from internal + # decomposition (combine_prefixes for da-/h-prefixed derived units, whose + # unit is dropped before rendering) both resolve to nil rather than an + # unvalidated bare string that would crash at render time. + return if name.strip.empty? || name == Utility::UNKNOWN + unless Unitsdb.prefixes.find_by_symbol_name(name) + raise Errors::UnknownPrefixError.new(value: name) + end + + Prefix.new(name) + end + def display_exp return unless power_numerator diff --git a/lib/unitsml/unitsdb/quantities.rb b/lib/unitsml/unitsdb/quantities.rb index 11383fa..bb68fbd 100644 --- a/lib/unitsml/unitsdb/quantities.rb +++ b/lib/unitsml/unitsdb/quantities.rb @@ -8,6 +8,26 @@ def find_by_id(q_id) quantity.identifiers.find { |id| id.id == q_id } end end + + # Resolve a quantity by its human name: English names + short slug, + # case-insensitive. Non-English synonyms are skipped (some contain + # commas the parser's comma-metadata handling would truncate). + def find_by_name(name) + return if name.to_s.strip.empty? + + key = name.to_s.downcase + quantities.find { |quantity| name_matches?(quantity, key) } + end + + private + + def name_matches?(quantity, key) + return true if quantity.short.to_s.downcase == key + + quantity.names.any? do |name| + name.lang == "en" && name.value.to_s.downcase == key + end + end end Configuration.register_model(Quantities, id: :quantities) diff --git a/lib/unitsml/unitsdb/units.rb b/lib/unitsml/unitsdb/units.rb index dddcc0d..7b829b6 100644 --- a/lib/unitsml/unitsdb/units.rb +++ b/lib/unitsml/unitsdb/units.rb @@ -32,6 +32,16 @@ def symbols_hash unit.symbols&.each { |unit_sym| object[unit_sym.id] = unit } end end + + def find_by_short(short) + short_hash[short] + end + + def short_hash + @short_hash ||= units.each_with_object({}) do |unit, object| + object[unit.short] = unit if unit.short + end + end end Configuration.register_model(Units, id: :units) diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index 61faf18..edbb5dd 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -55,7 +55,9 @@ def unit_instance(unit) end def quantity_instance(id) - Unitsdb.quantities.find_by_id(id) + return if id.nil? + + Unitsdb.quantities.find_by_id(id) || Unitsdb.quantities.find_by_name(id) end def units2dimensions(units) @@ -368,14 +370,19 @@ def dimension_components(dims) xml.force_encoding("UTF-8") end - def quantity(normtext, instance) + def quantity(normtext, instance, dims = nil) unit = unit_instance(normtext) return unless unit_or_quantity(unit, instance) - model_quantity_xml( - instance || unit.quantity_references&.first&.id, - "##{unit_dimension_id(unit)}", - ) + record = instance && quantity_instance(instance) + id = canonical_nist_id(record) || instance || + unit.quantity_references&.first&.id + url = unit ? "##{unit_dimension_id(unit)}" : "##{dim_id(dims)}" + model_quantity_xml(id, url, record&.quantity_type) + end + + def canonical_nist_id(record) + record&.identifiers&.find { |identifier| identifier.type == "nist" }&.id end def unit_nist_id(unit) @@ -409,13 +416,15 @@ def unit_or_quantity(unit, quantity) quantity_instance(quantity) end - def model_quantity_xml(id, url) - xml = Model::Quantity.new( + def model_quantity_xml(id, url, quantity_type = nil) + attrs = { id: id, name: quantity_name(id), dimension_url: url, lutaml_register: Configuration.context.id, - ).to_xml + } + attrs[:quantity_type] = quantity_type if quantity_type + xml = Model::Quantity.new(**attrs).to_xml xml.force_encoding("UTF-8") end diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb new file mode 100644 index 0000000..2e4ed86 --- /dev/null +++ b/spec/unitsml/composite_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Composite-unit builder (issue #2): the operator DSL (Unit#* / Unit#/) and the +# Unitsml.compose keyword form, both producing a root Formula whose output +# equals the equivalent parsed string. +RSpec.describe "Unitsml composite builder" do # rubocop:disable RSpec/DescribeClass + def unit(name, power = nil, prefix: nil) + Unitsml::Unit.new(name, power, prefix: prefix) + end + + describe "operator DSL" do + it "composes W/m/sr identically to the parsed expression" do + formula = unit("W") / unit("m") / unit("sr") + parsed = Unitsml.parse("W*m^-1*sr^-1") + formats = %i[to_latex to_asciimath to_unicode to_html to_xml to_mathml] + formats.each do |fmt| + expect(formula.public_send(fmt)).to eq(parsed.public_send(fmt)) + end + end + + it "returns a Formula, not a Unit" do + expect(unit("W") / unit("m")).to be_a(Unitsml::Formula) + end + + it "takes powers from the constructor (m/s^2)" do + formula = unit("m") / unit("s", 2) + expect(formula.to_latex).to eq(Unitsml.parse("m*s^-2").to_latex) + end + + it "does not mutate its operands" do + squared = unit("s", 2) + unit("m") / squared + expect(squared.power_numerator).to eq(2) + end + + it "raises when units and dimensions are mixed" do + expect { unit("m") * Unitsml::Dimension.new("dim_L") } + .to raise_error(Unitsml::Errors::MixedTermsError) + end + end + + describe "Unitsml.compose" do + let(:parsed) { Unitsml.parse("W*m^-1*sr^-1, quantity: radiance").to_xml } + + it "matches the parsed expression for mixed entry types" do + formula = Unitsml.compose( + units: ["W", { unit: "m", power: -1 }, { unit: "sr", power: -1 }], + quantity: "radiance", + ) + expect(formula.to_xml).to eq(parsed) + end + + it "accepts pre-built Unit entries" do + formula = Unitsml.compose( + units: [Unitsml::Unit.new("W"), { unit: "m", power: -1 }, + { unit: "sr", power: -1 }], + quantity: "radiance", + ) + expect(formula.to_xml).to eq(parsed) + end + + it "composes pure dimensions like the parser" do + formula = Unitsml.compose(units: ["dim_L", "dim_M"]) + expect(formula.to_xml).to eq(Unitsml.parse("dim_L*dim_M").to_xml) + end + + it "raises for an empty unit list" do + expect { Unitsml.compose(units: []) }.to raise_error(ArgumentError) + end + + it "raises when units and dimensions are mixed" do + expect { Unitsml.compose(units: ["W", "dim_L"]) } + .to raise_error(Unitsml::Errors::MixedTermsError) + end + + it "raises for an unknown unit reference" do + expect { Unitsml.compose(units: ["zzz"]) } + .to raise_error(Unitsml::Errors::UnknownUnitError) + end + end + + describe "render-time metadata" do + subject(:formula) { Unitsml::Unit.new("W") / Unitsml::Unit.new("m") } + + it "emits a Quantity passed to to_xml" do + expect(formula.to_xml(quantity: "NISTq89")).to include('xml:id="NISTq89"') + end + + it "ignores quantity/name for non-xml formats" do + expect { formula.to_latex(quantity: "radiance", name: "x") } + .not_to raise_error + end + + it "lets a render option override parsed comma-metadata" do + parsed = Unitsml.parse("kg*m, name: FROM_PARSE") + expect(parsed.to_xml(name: "FROM_OPTION")) + .to include(">FROM_OPTION") + end + + it "applies a multiplier passed to a render method" do + expect(formula.to_asciimath(multiplier: :nospace)).not_to include("*") + end + end + + describe "unit references" do + it "resolves a short name to the canonical symbol id" do + expect(Unitsml::Unit.new("watt")).to eq(Unitsml::Unit.new("W")) + end + + it "accepts a symbol reference" do + expect(Unitsml::Unit.new(:W).unit_name).to eq("W") + end + + it "raises for an unknown reference" do + expect { Unitsml::Unit.new("zzz") } + .to raise_error(Unitsml::Errors::UnknownUnitError, /zzz/) + end + + it "validates a string prefix" do + expect { Unitsml::Unit.new("m", prefix: "zz") } + .to raise_error(Unitsml::Errors::UnknownPrefixError) + end + end + + describe "power coercion (Number.coerce)" do + it "mirrors the parser exponent strings" do + cases = { 2 => "2", -1 => "-1", 1 => "1", 2.0 => "2", + Rational(1, 2) => "1/2", Rational(2, 1) => "2" } + cases.each do |input, expected| + expect(Unitsml::Number.coerce(input).raw_value).to eq(expected) + end + end + + it "rejects a non-integer Float power" do + expect { Unitsml::Number.coerce(0.5) }.to raise_error(ArgumentError) + end + + it "passes nil and existing Numbers through" do + number = Unitsml::Number.new("3") + expect(Unitsml::Number.coerce(nil)).to be_nil + expect(Unitsml::Number.coerce(number)).to be(number) + end + end + + describe "regressions" do + it "does not break parsing of da-/h-prefixed derived units" do + expect { Unitsml.parse("hPa").to_xml }.not_to raise_error + end + + it "renders division by an inverse term without a spurious ^1" do + formula = Unitsml::Unit.new("W") / Unitsml::Unit.new("A", -1) + expect(formula.to_xml).to eq(Unitsml.parse("W*A").to_xml) + expect(formula.to_asciimath).to eq(Unitsml.parse("W*A").to_asciimath) + end + + it "fails fast on a blank or missing unit reference" do + expect { Unitsml.compose(units: [{ power: 2 }]) } + .to raise_error(Unitsml::Errors::UnknownUnitError) + expect { Unitsml.compose(units: [""]) } + .to raise_error(Unitsml::Errors::UnknownUnitError) + end + + it "accepts a single Hash entry not wrapped in an array" do + formula = Unitsml.compose(units: { unit: "m", power: 2 }) + expect(formula.to_latex).to eq(Unitsml.parse("m^2").to_latex) + end + + it "inverts a parsed grouped or sqrt operand when dividing" do + grouped = Unitsml::Unit.new("W") / Unitsml.parse("((m*s))") + expect(grouped.to_xml).to eq(Unitsml.parse("W/((m*s))").to_xml) + rooted = Unitsml::Unit.new("W") / Unitsml.parse("sqrt(m)") + expect(rooted.to_xml).to eq(Unitsml.parse("W/sqrt(m)").to_xml) + end + + it "inverts a parsed operand containing internal division" do + divided = Unitsml::Unit.new("W") / Unitsml.parse("m/s") + expect(divided.to_xml).to eq(Unitsml.parse("W/(m/s)").to_xml) + doubled = Unitsml::Unit.new("W") / Unitsml.parse("m//s") + expect(doubled.to_xml).to eq(Unitsml.parse("W/(m//s)").to_xml) + end + + it "does not mutate a parsed operand it divides by" do + operand = Unitsml.parse("((m*s))") + before = operand.to_xml + Unitsml::Unit.new("W") / operand + expect(operand.to_xml).to eq(before) + end + + it "fails fast with the same error for a nil entry" do + expect { Unitsml.compose(units: [nil]) } + .to raise_error(Unitsml::Errors::UnknownUnitError) + end + + it "treats a blank prefix as no prefix rather than crashing" do + expect(Unitsml::Unit.new("m", prefix: "").prefix).to be_nil + end + end +end diff --git a/spec/unitsml/utility_quantity_spec.rb b/spec/unitsml/utility_quantity_spec.rb new file mode 100644 index 0000000..ec5f9ba --- /dev/null +++ b/spec/unitsml/utility_quantity_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Quantity-engine fix: resolve quantities by name/short (not only by id), +# emit a canonical NIST xml:id + the record's quantityType, and reference the +# emitted for composites. Scoped to the explicit-quantity path so +# the auto-emitted single-unit quantities stay byte-identical. +RSpec.describe "Unitsml quantity engine" do # rubocop:disable RSpec/DescribeClass + describe "Unitsml::Unitsdb::Quantities#find_by_name" do + subject(:quantities) { Unitsml::Unitsdb.quantities } + + it "resolves an English name to its record" do + expect(quantities.find_by_name("radiance")&.short).to eq("radiance") + end + + it "is case-insensitive" do + expect(quantities.find_by_name("Radiance")&.short).to eq("radiance") + end + + it "returns nil for blank input" do + expect(quantities.find_by_name("")).to be_nil + expect(quantities.find_by_name(nil)).to be_nil + end + + it "returns nil for an unknown name" do + expect(quantities.find_by_name("bogus")).to be_nil + end + end + + describe "Unitsml::Utility.quantity_instance" do + it "resolves by NIST id" do + record = Unitsml::Utility.quantity_instance("NISTq89") + expect(record&.short).to eq("radiance") + end + + it "falls back to name resolution" do + record = Unitsml::Utility.quantity_instance("radiance") + expect(record&.short).to eq("radiance") + end + + it "returns nil for a nil id" do + expect(Unitsml::Utility.quantity_instance(nil)).to be_nil + end + end + + describe "composite for an explicit quantity" do + let(:by_name) do + Unitsml.parse("W*m^-1*sr^-1, quantity: radiance").to_xml + end + + it "emits a canonical (NIST id, type, dimensionURL)" do + expect(by_name).to include('xml:id="NISTq89"') + expect(by_name).to include('quantityType="derived"') + expect(by_name).to include('dimensionURL="#D_LMT-3"') + expect(by_name).to include( + 'radiance', + ) + end + + it "resolves by case-insensitive name, NIST id, or unitsml id alike" do + %w[Radiance NISTq89 q:radiance].each do |ref| + xml = Unitsml.parse("W*m^-1*sr^-1, quantity: #{ref}").to_xml + expect(xml).to eq(by_name) + end + end + end + + describe "regression: no explicit quantity given" do + it "keeps an auto-emitted single-unit as base" do + hz = Unitsml.parse("Hz").to_xml + expect(hz).to include('xml:id="NISTq45"') + expect(hz).to include('quantityType="base"') + expect(hz).to include('dimensionURL="#NISTd24"') + end + + it "emits no without a single quantity reference" do + expect(Unitsml.parse("W").to_xml).not_to include(" Date: Thu, 2 Jul 2026 23:39:48 +0500 Subject: [PATCH 02/18] feat(compose): add dimensions keyword and harden the builder --- docs/README.adoc | 24 ++- lib/unitsml.rb | 12 +- lib/unitsml/composable.rb | 25 --- lib/unitsml/compose.rb | 14 ++ lib/unitsml/compose/builder.rb | 166 +++++++++++++++ lib/unitsml/compose/composable.rb | 45 ++++ lib/unitsml/compose/composite.rb | 163 +++++++++++++++ lib/unitsml/compose/term_tree.rb | 48 +++++ lib/unitsml/composite.rb | 59 ------ lib/unitsml/dimension.rb | 15 +- lib/unitsml/errors.rb | 4 + lib/unitsml/errors/empty_composition_error.rb | 14 ++ lib/unitsml/errors/invalid_power_error.rb | 31 +++ .../errors/invalid_unit_entry_error.rb | 34 +++ lib/unitsml/errors/unknown_dimension_error.rb | 17 ++ lib/unitsml/formula.rb | 146 +------------ lib/unitsml/number.rb | 31 +-- lib/unitsml/opal.rb | 11 +- lib/unitsml/unit.rb | 2 +- lib/unitsml/utility.rb | 7 +- spec/unitsml/composite_spec.rb | 195 ++++++++++++++++-- 21 files changed, 777 insertions(+), 286 deletions(-) delete mode 100644 lib/unitsml/composable.rb create mode 100644 lib/unitsml/compose.rb create mode 100644 lib/unitsml/compose/builder.rb create mode 100644 lib/unitsml/compose/composable.rb create mode 100644 lib/unitsml/compose/composite.rb create mode 100644 lib/unitsml/compose/term_tree.rb delete mode 100644 lib/unitsml/composite.rb create mode 100644 lib/unitsml/errors/empty_composition_error.rb create mode 100644 lib/unitsml/errors/invalid_power_error.rb create mode 100644 lib/unitsml/errors/invalid_unit_entry_error.rb create mode 100644 lib/unitsml/errors/unknown_dimension_error.rb diff --git a/docs/README.adoc b/docs/README.adoc index ae47cfd..2245a6f 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -178,12 +178,15 @@ Unitsml::Unit.new("m", prefix: "k") # km ---- A unit is referenced by its symbol id (as in a parsed string, e.g. `"W"`) or by -its `short` name (e.g. `"watt"`). An unknown unit, prefix or a units/dimensions -mix raises a `Unitsml::Errors::*` error. +its `short` name (e.g. `"watt"`). Dimensions compose the same way +(`Unitsml::Dimension.new("dim_L")`), but units and dimensions cannot be mixed in +one expression. -The `Unitsml.compose` keyword form builds the same `Formula` from a list, where -each entry is a symbol id, a `short` name, a `{ unit:, power:, prefix: }` hash, -or a pre-built `Unitsml::Unit`: +The `Unitsml.compose` keyword form builds the same `Formula` from a list. Pass +either `units:` or `dimensions:` (not both). A `units:` entry is a symbol id, a +`short` name, a `{ unit:, power:, prefix: }` hash, or a pre-built +`Unitsml::Unit`; a `dimensions:` entry is a dimension id or a +`{ dimension:, power: }` hash: [source,ruby] ---- @@ -191,6 +194,8 @@ Unitsml.compose( units: ["W", { unit: "m", power: -1 }, { unit: "sr", power: -1 }], quantity: "radiance", ) + +Unitsml.compose(dimensions: ["dim_L", { dimension: "dim_M", power: 2 }]) ---- The `quantity`, `name` and `multiplier` metadata are also accepted directly by @@ -204,6 +209,15 @@ formula = Unitsml.compose(units: ["W", { unit: "m", power: -1 }]) formula.to_xml(quantity: "radiance") # emits a element ---- +`Unitsml.compose` validates every reference, power and prefix it is given and +raises a `Unitsml::Errors::*` (all subclasses of `Unitsml::Errors::BaseError`) +for anything it cannot resolve: an unknown unit, dimension or prefix, a +units/dimensions mix, an empty composition, or a power the parser cannot express +(a decimal exponent — use a `Rational`). The operator DSL composes the objects +you construct, so — as with rendering a `Unit` or `Dimension` directly — a +hand-built object carrying an unresolvable prefix or dimension name is the +caller's responsibility. + === Format representation diff --git a/lib/unitsml.rb b/lib/unitsml.rb index 63927ac..3a47762 100644 --- a/lib/unitsml.rb +++ b/lib/unitsml.rb @@ -6,8 +6,7 @@ module Unitsml module_function - autoload :Composable, "unitsml/composable" - autoload :Composite, "unitsml/composite" + autoload :Compose, "unitsml/compose" autoload :Dimension, "unitsml/dimension" autoload :Configuration, "unitsml/configuration" autoload :Errors, "unitsml/errors" @@ -36,8 +35,11 @@ def parse(string) Unitsml::Parser.new(string).parse end - def compose(units:, quantity: nil, name: nil, multiplier: nil) - Unitsml::Composite.new(units: units, quantity: quantity, name: name, - multiplier: multiplier).to_formula + def compose(units: nil, dimensions: nil, + quantity: nil, name: nil, multiplier: nil) + Unitsml::Compose::Composite.new( + units: units, dimensions: dimensions, + quantity: quantity, name: name, multiplier: multiplier + ).to_formula end end diff --git a/lib/unitsml/composable.rb b/lib/unitsml/composable.rb deleted file mode 100644 index 9f45760..0000000 --- a/lib/unitsml/composable.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Unitsml - # Multiplicative composition for units, dimensions and formulas. `*` combines - # two operands; `/` combines with the right-hand side inverted. Both are - # non-mutating and return a fresh root Formula (the real assembly lives in - # Formula.build_product). Included by Unit, Dimension and Formula only. - module Composable - def *(other) - Formula.build_product(composable_terms, other, "*") - end - - def /(other) - Formula.build_product(composable_terms, other, "/") - end - - protected - - # A leaf (Unit/Dimension) contributes itself; Formula overrides this to - # contribute its already-interleaved term list. - def composable_terms - [self] - end - end -end diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb new file mode 100644 index 0000000..f64525f --- /dev/null +++ b/lib/unitsml/compose.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Unitsml + # Programmatic composite-unit construction: the operator DSL (Composable) and + # the Unitsml.compose keyword form (Composite), both assembled by Builder into + # a plain root Formula. Kept in its own namespace so the composition machinery + # does not leak into the core Unit/Dimension/Formula classes. + module Compose + autoload :Builder, "unitsml/compose/builder" + autoload :Composable, "unitsml/compose/composable" + autoload :Composite, "unitsml/compose/composite" + autoload :TermTree, "unitsml/compose/term_tree" + end +end diff --git a/lib/unitsml/compose/builder.rb b/lib/unitsml/compose/builder.rb new file mode 100644 index 0000000..854be8f --- /dev/null +++ b/lib/unitsml/compose/builder.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module Unitsml + module Compose + # Assembles composed root Formulas for both the operator DSL (build_product) + # and the keyword form (from_terms). Each leaf is REBUILT with its final + # power computed up front (coerce, negate for division, drop an + # inversion-produced ^1); the builder only READS operands and constructs + # fresh objects, so it can never mutate a caller's operand and never runs an + # in-place negation on shared state. Branch structure is rebuilt via + # TermTree; leaf text lives on Unit/Dimension. + module Builder + class << self + # Combine a left term list with the right operand's terms, interleaving + # multiplication. `/` inverts every right-hand leaf's power. + def build_product(left_terms, right_terms, operator) + left = build_terms(left_terms, invert: false) + right = build_terms(right_terms, invert: operator == "/") + build_root_formula(left + [mul_extender] + right) + end + + # Build a root Formula from an ordered list of unit/dimension operands, + # interleaving multiplication between them. + def from_terms(operands, quantity: nil, name: nil, multiplier: nil) + terms = interleave(build_terms(operands, invert: false)) + build_root_formula(terms, build_metadata(quantity, name, multiplier)) + end + + private + + def build_root_formula(terms, metadata = nil) + reject_mixed_terms!(terms) + text = terms_text(terms) + Formula.new(terms, explicit_value: metadata, root: true, + orig_text: text, norm_text: text) + end + + # --- leaf reconstruction (via TermTree.map_leaves) ----------------- + + # Rebuild every operand: map_leaves reconstructs branch wrappers and + # each Unit/Dimension leaf is copied with a freshly built final power + # (and a duplicated prefix). Non-leaf terms (Extender/Number) plain-dup. + def build_terms(terms, invert:) + TermTree.map_leaves(terms) { |leaf| rebuild_leaf(leaf, invert) } + end + + def rebuild_leaf(leaf, invert) + return leaf.dup unless leaf.is_a?(Unit) || leaf.is_a?(Dimension) + + copy = leaf.dup + copy.power_numerator = final_power(leaf.power_numerator, invert) + copy.prefix = leaf.prefix.dup if leaf.is_a?(Unit) && leaf.prefix + copy + end + + # The leaf's final exponent as a fresh Number (or nil). For division the + # exponent is negated; a negation that resolves to +1 (dividing by a + # ^-1 term) renders as no exponent, matching the parser, so it is + # dropped. A non-inverted explicit ^1 is preserved. + def final_power(power, invert) + raw = power_string(power) + return raw && Number.new(raw) unless invert + + negated = negate_string(raw) + negated == "1" ? nil : Number.new(negated) + end + + # A builder-supplied power as a parser-style exponent string (or nil): + # 1 -> "1", 1/2 -> "1/2", 2.0 -> "2". A non-integer Float is rejected + # (the parser has no decimal exponent); an existing Number is read by + # value, never shared. + def power_string(power) + case power + when nil then nil + when Number then power.raw_value + when Integer then power.to_s + when Rational then rational_string(power) + when Float then float_string(power) + else raise Errors::InvalidPowerError.new(value: power) + end + end + + def float_string(power) + unless power.finite? && power == power.to_i + raise Errors::InvalidPowerError.new(value: power, + reason: :non_integer_float) + end + + power.to_i.to_s + end + + def rational_string(power) + power.denominator == 1 ? power.numerator.to_s : power.to_s + end + + # Negate an exponent string: nil (no exponent) -> "-1", else flip sign. + def negate_string(raw) + return "-1" if raw.nil? + + raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}" + end + + # --- unit/dimension mixing guard (via TermTree.each_leaf) ---------- + + # Units and dimensions cannot share one expression (the XML serializer + # would drop the unit block); a single walk collects the leaf kinds. + def reject_mixed_terms!(terms) + kinds = leaf_kinds(terms) + return unless kinds.include?(Unit) && kinds.include?(Dimension) + + raise Errors::MixedTermsError + end + + def leaf_kinds(terms) + kinds = [] + TermTree.each_leaf(terms) do |leaf| + kinds << leaf.class if leaf.is_a?(Unit) || leaf.is_a?(Dimension) + end + kinds + end + + # --- text synthesis ------------------------------------------------ + + # Reconstruct parser-style source text; leaf text lives on the domain + # objects (Unit/Dimension#xml_postprocess_name) and branches wrap their + # inner text. Recurses over the same tree shape as TermTree. + def terms_text(term) + case term + when Array then term.map { |child| terms_text(child) }.join + when Unit, Dimension then term.xml_postprocess_name + when Extender then term.symbol + else branch_text(term) + end + end + + def branch_text(term) + case term + when Fenced + "#{term.open_paren}#{terms_text(term.value)}#{term.close_paren}" + when Sqrt then "sqrt(#{terms_text(term.value)})" + when Formula then terms_text(term.value) + else "" + end + end + + # --- small helpers ------------------------------------------------- + + def interleave(operands) + operands.each_with_index.flat_map do |operand, index| + index.zero? ? [operand] : [mul_extender, operand] + end + end + + def build_metadata(quantity, name, multiplier) + metadata = { quantity: quantity, name: name, + multiplier: multiplier }.compact + metadata.empty? ? nil : metadata + end + + def mul_extender + Extender.new("*") + end + end + end + end +end diff --git a/lib/unitsml/compose/composable.rb b/lib/unitsml/compose/composable.rb new file mode 100644 index 0000000..2f76a63 --- /dev/null +++ b/lib/unitsml/compose/composable.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Unitsml + module Compose + # Multiplicative composition mixin for units, dimensions and formulas. `*` + # combines two operands; `/` combines with the right-hand side inverted. + # Both are non-mutating and return a fresh root Formula (assembly lives in + # Compose::Builder). Included by Unit, Dimension and Formula only. + module Composable + def *(other) + build_formula(other, "*") + end + + def /(other) + build_formula(other, "/") + end + + # The term list this operand contributes to a composition. A leaf + # (Unit/Dimension) contributes itself; Formula overrides this to + # contribute its already-interleaved value. Public so an operand can + # supply its terms to the other side of `*`/`/`. + def composable_terms + [self] + end + + private + + def build_formula(other, operator) + Builder.build_product(composable_terms, operand_terms(other), operator) + end + + # Only a Unit, Dimension or Formula is composable; anything else fails + # fast here rather than exploding deeper inside Builder. + def operand_terms(other) + return other.composable_terms if composable?(other) + + raise Errors::InvalidUnitEntryError.new(value: other, field: :operand) + end + + def composable?(other) + other.is_a?(Unit) || other.is_a?(Dimension) || other.is_a?(Formula) + end + end + end +end diff --git a/lib/unitsml/compose/composite.rb b/lib/unitsml/compose/composite.rb new file mode 100644 index 0000000..4723733 --- /dev/null +++ b/lib/unitsml/compose/composite.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module Unitsml + module Compose + # Keyword builder behind Unitsml.compose. Resolves whether the request is a + # unit or a dimension composition (never both), coerces each entry into a + # domain object, and hands the list to Builder. A helper, not a Formula + # subclass: #to_formula returns a plain root Formula. quantity/name/ + # multiplier are metadata passed straight through (the render layer ignores + # what does not apply, e.g. quantity/name for a dimension composition). + class Composite + def initialize(units: nil, dimensions: nil, + quantity: nil, name: nil, multiplier: nil) + @quantity = quantity + @name = name + @multiplier = multiplier + classify(normalize(units), normalize(dimensions)) + end + + def to_formula + Builder.from_terms(@entries.map { |entry| coerce_entry(entry) }, + quantity: @quantity, name: @name, + multiplier: @multiplier) + end + + private + + # A lone Hash is a single entry; nil is nothing; anything else is a list. + def normalize(entries) + return [] if entries.nil? + + entries.is_a?(Hash) ? [entries] : Array(entries) + end + + # Exactly one of units:/dimensions: may be non-empty. Store the resolved + # kind and its entries; both -> mix, neither -> empty. + def classify(units, dimensions) + # Presence is emptiness, not .any? — [nil] is a (bad) entry to reject, + # not an empty list. + has_units = !units.empty? + has_dimensions = !dimensions.empty? + + if has_units && has_dimensions + raise Errors::MixedTermsError.new( + "[unitsml] Pass either units: or dimensions:, not both.", + ) + elsif has_units + @kind = :unit + @entries = units + elsif has_dimensions + @kind = :dimension + @entries = dimensions + else + raise Errors::EmptyCompositionError + end + end + + def coerce_entry(entry) + case entry + when Unit, Dimension then typed_entry(entry) + when Hash then build_from_hash(entry) + when String, Symbol then build_reference(entry.to_s) + when nil then blank_reference + else raise Errors::InvalidUnitEntryError.new(value: entry, field: @kind) + end + end + + # A pre-built object must match the composition's kind (a Dimension in + # units:, or a Unit in dimensions:, is rejected). It is then rebuilt + # through the same validated path as a Hash entry so its name, power and + # prefix are normalized/validated — otherwise a Symbol name, an invalid + # Number power, or an unresolved Prefix object slips through and crashes + # (or renders non-parseably) at render time. + def typed_entry(entry) + unless entry.is_a?(expected_class) + raise Errors::InvalidUnitEntryError.new(value: entry, field: @kind) + end + + @kind == :dimension ? rebuild_dimension(entry) : rebuild_unit(entry) + end + + def rebuild_unit(unit) + Unit.new(require_reference(unit.unit_name), + require_power(unit.power_numerator), + prefix: prefix_ref(unit.prefix)) + end + + def rebuild_dimension(dimension) + Dimension.new(require_dimension(dimension.dimension_name), + require_power(dimension.power_numerator)) + end + + def build_from_hash(entry) + return build_dimension_hash(entry) if @kind == :dimension + + Unit.new(require_reference(entry[:unit]), require_power(entry[:power]), + prefix: prefix_ref(entry[:prefix])) + end + + def build_dimension_hash(entry) + if entry.key?(:prefix) + raise Errors::InvalidUnitEntryError.new(value: entry, field: :prefix) + end + + Dimension.new(require_dimension(entry[:dimension]), + require_power(entry[:power])) + end + + def build_reference(reference) + return Unit.new(require_reference(reference)) if @kind == :unit + + Dimension.new(require_dimension(reference)) + end + + # A unit reference just needs to be non-blank; Unit.new fail-fasts on an + # unresolvable (or dim_*) name, which is what makes units: units-only. + def require_reference(reference) + string = reference.to_s + return string unless string.strip.empty? + + raise Errors::UnknownUnitError.new(value: reference) + end + + # A dimension reference is validated eagerly (Dimension.new does not), + # keeping compose fail-fast like the parser. + def require_dimension(reference) + string = reference.to_s + return string if Unitsdb.dimensions.parsables.key?(string) + + raise Errors::UnknownDimensionError.new(value: reference) + end + + def blank_reference + @kind == :dimension ? require_dimension(nil) : require_reference(nil) + end + + # A user-supplied power wrapped in a Number must still be a parser-valid + # exponent. This regex mirrors the grammar's `slashed_number` + # (parse.rb: a signed integer, optionally `/` or `//` then a signed + # integer), so forms like "1/-2" and "1//2" are accepted while a decimal + # ("0.5") or non-numeric string has no parsed equivalent and is rejected. + # Other power types are validated by Builder during assembly. + def require_power(power) + return power unless power.is_a?(Number) + return power if power.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z}) + + raise Errors::InvalidPowerError.new(value: power, + reason: :non_integer_float) + end + + # A prefix passed as a Prefix object is reduced to its name so Unit.new + # validates it (a bad name raises UnknownPrefixError); an unvalidated + # Prefix object would otherwise leak a NoMethodError at render. + def prefix_ref(prefix) + prefix.is_a?(Prefix) ? prefix.prefix_name : prefix + end + + def expected_class + @kind == :dimension ? Dimension : Unit + end + end + end +end diff --git a/lib/unitsml/compose/term_tree.rb b/lib/unitsml/compose/term_tree.rb new file mode 100644 index 0000000..b69882b --- /dev/null +++ b/lib/unitsml/compose/term_tree.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Unitsml + module Compose + # Single source of truth for the compose term-tree shape, so every traversal + # (deep-copy, inversion, unit/dimension detection) is written once, not + # re-encoded per traversal. The tree is: + # Array -> each element is a node + # Formula -> branch over its `value` array + # Fenced, Sqrt -> branch over a single `value` + # Unit, Dimension, + # Extender, Number... -> leaf + module TermTree + module_function + + # Visit every leaf depth-first (branches are traversed, not yielded). + # Returns the node unchanged — this is a walk for side effects on leaves. + def each_leaf(node, &block) + case node + when Array then node.each { |child| each_leaf(child, &block) } + when Formula, Fenced, Sqrt then each_leaf(node.value, &block) + else yield node + end + node + end + + # Rebuild the branch structure, replacing each leaf with the block's + # result. Branches are always reconstructed, so the original is untouched. + def map_leaves(node, &block) + case node + when Array then node.map { |child| map_leaves(child, &block) } + when Formula then map_formula(node, &block) + when Fenced + Fenced.new(node.open_paren, map_leaves(node.value, &block), + node.close_paren) + when Sqrt then Sqrt.new(map_leaves(node.value, &block)) + else yield node + end + end + + def map_formula(formula, &block) + copy = formula.dup + copy.value = map_leaves(formula.value, &block) + copy + end + end + end +end diff --git a/lib/unitsml/composite.rb b/lib/unitsml/composite.rb deleted file mode 100644 index c3e5b82..0000000 --- a/lib/unitsml/composite.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module Unitsml - # Programmatic keyword builder behind Unitsml.compose. It is a helper, NOT a - # Formula subclass: #to_formula materialises the composed root Formula, so the - # rendered object's class stays Formula. Entries mirror what the parser - # accepts - units or dimensions, never a mix (Formula.from_terms enforces it). - class Composite - def initialize(units:, quantity: nil, name: nil, multiplier: nil) - @units = units.is_a?(Hash) ? [units] : Array(units) - @quantity = quantity - @name = name - @multiplier = multiplier - end - - def to_formula - Formula.from_terms(@units.map { |entry| coerce_entry(entry) }, - quantity: @quantity, name: @name, - multiplier: @multiplier) - end - - private - - def coerce_entry(entry) - case entry - when Unit, Dimension then entry - when Hash then build_unit(entry) - when String, Symbol then build_reference(entry.to_s) - when nil then validate_reference(nil) - else raise ArgumentError, "invalid unit entry: #{entry.inspect}" - end - end - - def build_unit(entry) - Unit.new(validate_reference(entry[:unit]), entry[:power], - prefix: entry[:prefix]) - end - - # A dim_* reference builds a Dimension (as the parser would); anything else - # resolves as a unit and fails fast on an unknown reference. - def build_reference(reference) - reference = validate_reference(reference) - return Dimension.new(reference) if dimension_reference?(reference) - - Unit.new(reference) - end - - def validate_reference(reference) - string = reference.to_s - return string unless string.strip.empty? - - raise Errors::UnknownUnitError.new(value: reference) - end - - def dimension_reference?(reference) - Unitsdb.dimensions.parsables.key?(reference) - end - end -end diff --git a/lib/unitsml/dimension.rb b/lib/unitsml/dimension.rb index 5537f5c..635b591 100644 --- a/lib/unitsml/dimension.rb +++ b/lib/unitsml/dimension.rb @@ -3,7 +3,7 @@ module Unitsml class Dimension include MathmlHelper - include Composable + include Compose::Composable attr_accessor :dimension_name, :power_numerator @@ -80,8 +80,21 @@ def modelize(value) value&.split("_")&.map(&:capitalize)&.join end + # Parser-style source text for the dimension, e.g. "dim_L" or "dim_L^2". + # Mirrors Unit#xml_postprocess_name so composed text is built the same way. + def xml_postprocess_name + "#{dimension_name}#{display_exp}" + end + private + def display_exp + return unless power_numerator + + exp = power_numerator.raw_value + "^#{exp}" if exp != "1" + end + def html_numerator_conversion(options) "#{power_numerator.to_html(options)}" end diff --git a/lib/unitsml/errors.rb b/lib/unitsml/errors.rb index ed9d1ae..1c7f254 100644 --- a/lib/unitsml/errors.rb +++ b/lib/unitsml/errors.rb @@ -3,11 +3,15 @@ module Unitsml module Errors autoload :BaseError, "unitsml/errors/base_error" + autoload :EmptyCompositionError, "unitsml/errors/empty_composition_error" autoload :InvalidModelError, "unitsml/errors/invalid_model_error" + autoload :InvalidPowerError, "unitsml/errors/invalid_power_error" + autoload :InvalidUnitEntryError, "unitsml/errors/invalid_unit_entry_error" autoload :MixedTermsError, "unitsml/errors/mixed_terms_error" autoload :OpalPayloadNotBundledError, "unitsml/errors/opal_payload_not_bundled_error" autoload :PlurimathLoadError, "unitsml/errors/plurimath_load_error" + autoload :UnknownDimensionError, "unitsml/errors/unknown_dimension_error" autoload :UnknownPrefixError, "unitsml/errors/unknown_prefix_error" autoload :UnknownUnitError, "unitsml/errors/unknown_unit_error" autoload :UnsupportedPayloadTypeError, diff --git a/lib/unitsml/errors/empty_composition_error.rb b/lib/unitsml/errors/empty_composition_error.rb new file mode 100644 index 0000000..9f84e02 --- /dev/null +++ b/lib/unitsml/errors/empty_composition_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # Raised when Unitsml.compose is given nothing to build from — neither a + # non-empty units: nor dimensions: list. + class EmptyCompositionError < Unitsml::Errors::BaseError + def initialize(msg = nil) + super(msg || "[unitsml] Nothing to compose — supply a non-empty " \ + "`units:` or `dimensions:` list.") + end + end + end +end diff --git a/lib/unitsml/errors/invalid_power_error.rb b/lib/unitsml/errors/invalid_power_error.rb new file mode 100644 index 0000000..4b5f941 --- /dev/null +++ b/lib/unitsml/errors/invalid_power_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # Raised when a builder-supplied power cannot become a parser-style + # exponent: an unsupported type, or a non-integer / non-finite Float (the + # parser has no decimal exponent — pass a Rational instead). + class InvalidPowerError < Unitsml::Errors::BaseError + attr_reader :value, :reason + + def initialize(value:, reason: :unsupported_type) + @value = value + @reason = reason + super(message_for(reason, value)) + end + + private + + def message_for(reason, value) + case reason + when :non_integer_float + "[unitsml] Non-integer Float power: #{value.inspect} — use a " \ + "Rational (e.g. Rational(1, 2)) for a fractional exponent." + else + "[unitsml] Unsupported power: #{value.inspect} — expected an " \ + "Integer, Rational, Float or Unitsml::Number." + end + end + end + end +end diff --git a/lib/unitsml/errors/invalid_unit_entry_error.rb b/lib/unitsml/errors/invalid_unit_entry_error.rb new file mode 100644 index 0000000..c3d867d --- /dev/null +++ b/lib/unitsml/errors/invalid_unit_entry_error.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # Raised when a compose entry (or operator operand) is not something the + # builder can turn into a term: the wrong type, the wrong kind for the mode + # (a Dimension in units: / a Unit in dimensions:), a prefix on a dimension, + # or a non-composable `*`/`/` operand. + class InvalidUnitEntryError < Unitsml::Errors::BaseError + attr_reader :value, :field + + def initialize(value:, field: :entry) + @value = value + @field = field + super(message_for(field, value)) + end + + private + + def message_for(field, value) + case field + when :operand + "[unitsml] Cannot compose with #{value.inspect} — expected a " \ + "Unit, Dimension or Formula." + when :prefix + "[unitsml] A dimension entry cannot take a prefix: #{value.inspect}." + else + "[unitsml] Invalid #{field} entry: #{value.inspect} — expected a " \ + "reference (String/Symbol), a Hash, or a matching Unit/Dimension." + end + end + end + end +end diff --git a/lib/unitsml/errors/unknown_dimension_error.rb b/lib/unitsml/errors/unknown_dimension_error.rb new file mode 100644 index 0000000..3d12fb8 --- /dev/null +++ b/lib/unitsml/errors/unknown_dimension_error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # Raised when a dimension reference cannot be resolved. Dimension.new has no + # existence check, so compose validates eagerly to stay fail-fast. + class UnknownDimensionError < Unitsml::Errors::BaseError + attr_reader :value + + def initialize(value:) + @value = value + super("[unitsml] Unknown dimension reference: #{value.inspect} — " \ + "expected a dimension id (e.g. \"dim_L\").") + end + end + end +end diff --git a/lib/unitsml/formula.rb b/lib/unitsml/formula.rb index fdf0fec..1a64da7 100644 --- a/lib/unitsml/formula.rb +++ b/lib/unitsml/formula.rb @@ -6,7 +6,7 @@ module Unitsml class Formula include MathmlHelper - include Composable + include Compose::Composable attr_accessor :value, :explicit_value, :root @@ -90,150 +90,14 @@ def dimensions_extraction extract_dimensions(value) end - class << self - # Assemble a root Formula from a left-hand term list and a right-hand - # operand. `/` inverts every unit/dimension term on the right. Terms are - # deep-copied so operands are never mutated. - def build_product(lhs_terms, rhs, operator) - lhs = lhs_terms.map { |term| dup_term(term) } - rhs_terms = terms_of(rhs).map { |term| dup_term(term) } - invert_terms(rhs_terms) if operator == "/" - terms = lhs + [Extender.new("*")] + rhs_terms - guard_terms!(terms) - text = synthesize_text(terms) - new(terms, root: true, orig_text: text, norm_text: text) - end - - # Build a root Formula from an ordered list of unit/dimension objects, - # interleaving multiplication. Used by the keyword compose builder. - def from_terms(objects, quantity: nil, name: nil, multiplier: nil) - raise ArgumentError, "compose requires at least one unit" if objects.empty? - - terms = interleave(objects.map { |object| dup_term(object) }) - guard_terms!(terms) - text = synthesize_text(terms) - new(terms, explicit_value: build_extras(quantity, name, multiplier), - root: true, orig_text: text, norm_text: text) - end - - private - - def interleave(objects) - objects.each_with_index.flat_map do |object, index| - index.zero? ? [object] : [Extender.new("*"), object] - end - end - - def build_extras(quantity, name, multiplier) - extras = { quantity: quantity, name: name, - multiplier: multiplier }.compact - extras.empty? ? nil : extras - end - - def terms_of(rhs) - case rhs - when Formula then rhs.value - when Unit, Dimension then [rhs] - else raise ArgumentError, "cannot compose with #{rhs.inspect}" - end - end - - # Deep-copy a term so composing never mutates an operand. Nested Formula, - # Fenced and Sqrt structures are copied recursively (a parsed operand can - # carry them), and unit/dimension powers are coerced + duplicated. - def dup_term(term) - case term - when Unit then dup_leaf(term, dup_prefix: true) - when Dimension then dup_leaf(term) - when Formula then dup_formula(term) - when Fenced then Fenced.new(term.open_paren, dup_term(term.value), - term.close_paren) - when Sqrt then Sqrt.new(dup_term(term.value)) - else term.dup - end - end - - def dup_leaf(term, dup_prefix: false) - copy = term.dup - power = Number.coerce(copy.power_numerator) - copy.power_numerator = power.is_a?(Number) ? power.dup : power - copy.prefix = copy.prefix.dup if dup_prefix && copy.prefix - copy - end - - def dup_formula(formula) - copy = formula.dup - copy.value = formula.value.map { |term| dup_term(term) } - copy - end - - # Invert every unit/dimension term (division). The right-hand term list is - # already exponent-resolved - the parser bakes division into the powers and - # leaves "/" extenders decorative - so each leaf is negated and extenders - # are ignored; Fenced/Sqrt/nested Formula structures recurse. - def invert_terms(terms) - terms.each do |term| - case term - when Unit then invert_unit(term) - when Dimension then invert_dimension(term) - when Fenced, Sqrt then invert_terms([term.value]) - when Formula then invert_terms(term.value) - end - end - end - - # A resolved exponent of +1 renders as no exponent (canonical plain unit), - # so dividing by an inverse term matches the parsed equivalent. - def invert_unit(unit) - unit.inverse_power_numerator - unit.power_numerator = nil if unit.power_numerator&.raw_value == "1" - end - - def invert_dimension(dimension) - raw = dimension.power_numerator&.raw_value - negated = raw ? negate(raw) : "-1" - dimension.power_numerator = negated == "1" ? nil : Number.new(negated) - end - - def negate(raw) - raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}" - end - - def guard_terms!(terms) - raise Errors::MixedTermsError if terms.any?(Unit) && - terms.any?(Dimension) - end - - def synthesize_text(terms) - terms.map { |term| term_text(term) }.join - end - - def term_text(term) - case term - when Unit then term.xml_postprocess_name - when Dimension then dimension_text(term) - when Extender then term.symbol - when Fenced - "#{term.open_paren}#{term_text(term.value)}#{term.close_paren}" - when Sqrt then "sqrt(#{term_text(term.value)})" - when Formula then synthesize_text(term.value) - else "" - end - end - - def dimension_text(dimension) - exponent = dimension.power_numerator&.raw_value - suffix = exponent && exponent != "1" ? "^#{exponent}" : "" - "#{dimension.dimension_name}#{suffix}" - end - end - - private - + # A composed Formula contributes its already-interleaved term list (the + # Composable default of [self] is only right for a single leaf). def composable_terms value end + private + def extract_dimensions(formula) formula.each_with_object([]) do |term, dimensions| case term diff --git a/lib/unitsml/number.rb b/lib/unitsml/number.rb index 4d6b847..3b8dede 100644 --- a/lib/unitsml/number.rb +++ b/lib/unitsml/number.rb @@ -9,32 +9,11 @@ class Number alias raw_value value def initialize(value) - @value = value - end - - # Coerce a builder-supplied power into a Number, mirroring the exponent - # strings the parser produces: 1 -> "1", -1 -> "-1", 1/2 -> "1/2", - # 2.0 -> "2". Non-integer Floats are rejected (the parser cannot express a - # decimal exponent) - pass a Rational instead. nil and existing power - # objects pass through untouched. - def self.coerce(power) - case power - when nil, Number then power - when Integer then new(power.to_s) - when Rational - new(power.denominator == 1 ? power.numerator.to_s : power.to_s) - when Float then coerce_float(power) - else raise ArgumentError, "unsupported power: #{power.inspect}" - end - end - - def self.coerce_float(power) - unless power.finite? && power == power.to_i - raise ArgumentError, "non-integer Float power #{power.inspect}; " \ - "use a Rational (e.g. Rational(1, 2))" - end - - new(power.to_i.to_s) + # Number always holds a String representation; the render/compare helpers + # (to_html, negative?, update_negative_sign, ...) assume String, so a + # numeric value (e.g. a Float exponent from unit decomposition) is + # normalized here rather than crashing later. + @value = value.to_s end def ==(other) diff --git a/lib/unitsml/opal.rb b/lib/unitsml/opal.rb index bf2f8aa..e2b9eb1 100644 --- a/lib/unitsml/opal.rb +++ b/lib/unitsml/opal.rb @@ -33,10 +33,14 @@ require "unitsml/version" require "unitsml/errors" require "unitsml/errors/base_error" +require "unitsml/errors/empty_composition_error" require "unitsml/errors/invalid_model_error" +require "unitsml/errors/invalid_power_error" +require "unitsml/errors/invalid_unit_entry_error" require "unitsml/errors/mixed_terms_error" require "unitsml/errors/opal_payload_not_bundled_error" require "unitsml/errors/plurimath_load_error" +require "unitsml/errors/unknown_dimension_error" require "unitsml/errors/unknown_prefix_error" require "unitsml/errors/unknown_unit_error" require "unitsml/errors/unsupported_payload_type_error" @@ -85,8 +89,11 @@ require "unitsml/model/units/symbol" require "unitsml/model/units/system" -require "unitsml/composable" -require "unitsml/composite" +require "unitsml/compose" +require "unitsml/compose/term_tree" +require "unitsml/compose/builder" +require "unitsml/compose/composable" +require "unitsml/compose/composite" require "unitsml/dimension" require "unitsml/extender" require "unitsml/fenced" diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index 6c49102..f524e18 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -3,7 +3,7 @@ module Unitsml class Unit include MathmlHelper - include Composable + include Compose::Composable attr_accessor :unit_name, :power_numerator, :prefix diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index edbb5dd..e65efb9 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -375,8 +375,11 @@ def quantity(normtext, instance, dims = nil) return unless unit_or_quantity(unit, instance) record = instance && quantity_instance(instance) - id = canonical_nist_id(record) || instance || - unit.quantity_references&.first&.id + # An explicit but unresolvable quantity emits nothing (silent), rather + # than leaking the raw reference as an xml:id. + return if instance && record.nil? + + id = canonical_nist_id(record) || unit.quantity_references&.first&.id url = unit ? "##{unit_dimension_id(unit)}" : "##{dim_id(dims)}" model_quantity_xml(id, url, record&.quantity_type) end diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 2e4ed86..7dc15ce 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -61,17 +61,20 @@ def unit(name, power = nil, prefix: nil) expect(formula.to_xml).to eq(parsed) end - it "composes pure dimensions like the parser" do - formula = Unitsml.compose(units: ["dim_L", "dim_M"]) - expect(formula.to_xml).to eq(Unitsml.parse("dim_L*dim_M").to_xml) + it "raises for an empty composition" do + expect { Unitsml.compose(units: []) } + .to raise_error(Unitsml::Errors::EmptyCompositionError) + expect { Unitsml.compose } + .to raise_error(Unitsml::Errors::EmptyCompositionError) end - it "raises for an empty unit list" do - expect { Unitsml.compose(units: []) }.to raise_error(ArgumentError) + it "rejects a dim_* reference in units: (units-only)" do + expect { Unitsml.compose(units: ["W", "dim_L"]) } + .to raise_error(Unitsml::Errors::UnknownUnitError) end - it "raises when units and dimensions are mixed" do - expect { Unitsml.compose(units: ["W", "dim_L"]) } + it "raises when both units: and dimensions: are given" do + expect { Unitsml.compose(units: ["W"], dimensions: ["dim_L"]) } .to raise_error(Unitsml::Errors::MixedTermsError) end @@ -79,6 +82,53 @@ def unit(name, power = nil, prefix: nil) expect { Unitsml.compose(units: ["zzz"]) } .to raise_error(Unitsml::Errors::UnknownUnitError) end + + it "rejects a pre-built Dimension in units:" do + expect { Unitsml.compose(units: [Unitsml::Dimension.new("dim_L")]) } + .to raise_error(Unitsml::Errors::InvalidUnitEntryError) + end + + it "raises every compose failure under Errors::BaseError" do + expect { Unitsml.compose(units: []) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml.compose(units: [{ unit: "m", power: 0.5 }]) } + .to raise_error(Unitsml::Errors::BaseError) + end + end + + describe "Unitsml.compose dimensions" do + it "composes pure dimensions like the parser" do + formula = Unitsml.compose(dimensions: ["dim_L", "dim_M"]) + expect(formula.to_xml).to eq(Unitsml.parse("dim_L*dim_M").to_xml) + end + + it "accepts a {dimension:, power:} hash like dim_L^2" do + formula = Unitsml.compose(dimensions: [{ dimension: "dim_L", power: 2 }]) + expect(formula.to_xml).to eq(Unitsml.parse("dim_L^2").to_xml) + end + + it "raises UnknownDimensionError for an unknown dimension" do + expect { Unitsml.compose(dimensions: ["dim_bogus"]) } + .to raise_error(Unitsml::Errors::UnknownDimensionError) + end + + it "rejects a prefix on a dimension entry" do + expect do + Unitsml.compose(dimensions: [{ dimension: "dim_L", prefix: "k" }]) + end.to raise_error(Unitsml::Errors::InvalidUnitEntryError) + end + + it "rejects a pre-built Unit in dimensions:" do + expect { Unitsml.compose(dimensions: [Unitsml::Unit.new("W")]) } + .to raise_error(Unitsml::Errors::InvalidUnitEntryError) + end + + it "ignores quantity/name metadata for a dimension composition" do + formula = Unitsml.compose(dimensions: ["dim_L"], + quantity: "length", name: "L") + expect { formula.to_xml }.not_to raise_error + expect(formula.to_xml).not_to include(" "2", -1 => "-1", 1 => "1", 2.0 => "2", - Rational(1, 2) => "1/2", Rational(2, 1) => "2" } - cases.each do |input, expected| - expect(Unitsml::Number.coerce(input).raw_value).to eq(expected) - end + describe "power coercion" do + def latex_for(power) + Unitsml.compose(units: [{ unit: "m", power: power }]).to_latex + end + + it "mirrors parser exponent strings for valid powers" do + expect(latex_for(2)).to eq(Unitsml.parse("m^2").to_latex) + expect(latex_for(2.0)).to eq(Unitsml.parse("m^2").to_latex) + expect(latex_for(Rational(2, 1))).to eq(Unitsml.parse("m^2").to_latex) + expect(latex_for(Rational(1, 2))).to match(%r{\^1/2$}) + expect(latex_for(Unitsml::Number.new("3"))) + .to eq(Unitsml.parse("m^3").to_latex) + end + + it "keeps an explicit exponent of 1, treats nil as no exponent" do + expect(latex_for(1)).to eq(Unitsml.parse("m^1").to_latex) + expect(latex_for(nil)).to eq(Unitsml.parse("m").to_latex) end it "rejects a non-integer Float power" do - expect { Unitsml::Number.coerce(0.5) }.to raise_error(ArgumentError) + expect { latex_for(0.5) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + end + + describe "operand-safety (pure leaf construction)" do + it "duplicates the prefix so operand and result never share it" do + km = Unitsml::Unit.new("m", prefix: "k") + original = km.prefix + result = km * Unitsml::Unit.new("s") + expect(km.prefix).to be(original) + copied = result.value.first.prefix + expect(copied).not_to be(original) + expect(copied.prefix_name).to eq(original.prefix_name) + end + + it "does not mutate a parsed sqrt operand it divides by" do + op = Unitsml.parse("sqrt(m)") + before = op.to_xml + Unitsml::Unit.new("W") / op + expect(op.to_xml).to eq(before) + end + + it "composes a sqrt-dimension operand without mutating it" do + op = Unitsml.parse("sqrt(dim_L)") + before = op.to_xml + Unitsml::Dimension.new("dim_M") * op + expect(op.to_xml).to eq(before) + end + end + + describe "adversarial hardening (bug-hunt findings)" do + it "rejects a fractional pre-built Number power like a Float" do + half = Unitsml::Number.new("0.5") + expect { Unitsml.compose(units: [{ unit: "m", power: half }]) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + it "still accepts an integer or fraction pre-built Number power" do + three = Unitsml::Number.new("3") + expect(Unitsml.compose(units: [{ unit: "m", power: three }]).to_latex) + .to eq(Unitsml.parse("m^3").to_latex) + half = Unitsml::Number.new("1/2") + expect(Unitsml.compose(units: [{ unit: "m", power: half }]).to_latex) + .to match(%r{\^1/2$}) + end + + it "fails fast on a pre-built Dimension with an unknown name" do + expect { Unitsml.compose(dimensions: [Unitsml::Dimension.new("dim_bogus")]) } + .to raise_error(Unitsml::Errors::UnknownDimensionError) + end + + it "validates a pre-built Prefix object like a string prefix" do + bad = Unitsml::Prefix.new("zz") + expect { Unitsml.compose(units: [{ unit: "m", prefix: bad }]) } + .to raise_error(Unitsml::Errors::UnknownPrefixError) + end + + it "accepts a valid pre-built Prefix object" do + k = Unitsml::Prefix.new("k") + obj = Unitsml.compose(units: [{ unit: "m", prefix: k }]) + str = Unitsml.compose(units: [{ unit: "m", prefix: "k" }]) + expect(obj.to_xml).to eq(str.to_xml) + end + + it "normalizes a pre-built Dimension with a Symbol name" do + sym = Unitsml.compose(dimensions: [Unitsml::Dimension.new(:dim_L)]) + str = Unitsml.compose(dimensions: ["dim_L"]) + expect(sym.to_xml).to eq(str.to_xml) + end + + it "validates the prefix of a pre-built Unit entry" do + bad = Unitsml::Unit.new("m", prefix: Unitsml::Prefix.new("zz")) + expect { Unitsml.compose(units: [bad]) } + .to raise_error(Unitsml::Errors::UnknownPrefixError) + end + + it "validates the power of a pre-built Unit entry" do + bad = Unitsml::Unit.new("m", Unitsml::Number.new("abc")) + expect { Unitsml.compose(units: [bad]) } + .to raise_error(Unitsml::Errors::InvalidPowerError) end - it "passes nil and existing Numbers through" do - number = Unitsml::Number.new("3") - expect(Unitsml::Number.coerce(nil)).to be_nil - expect(Unitsml::Number.coerce(number)).to be(number) + it "accepts slashed Number exponents the parser accepts" do + %w[1/-2 1//2].each do |raw| + pow = Unitsml::Number.new(raw) + got = Unitsml.compose(units: [{ unit: "m", power: pow }]).to_latex + expect(got).to eq(Unitsml.parse("m^(#{raw})").to_latex) + end + end + + it "fails loud (not a crash) on an unsupported fenced-exponent operand" do + expect { Unitsml::Unit.new("W") / Unitsml.parse("m^((1/2))") } + .to raise_error(Unitsml::Errors::BaseError) end end @@ -196,5 +343,15 @@ def unit(name, power = nil, prefix: nil) it "treats a blank prefix as no prefix rather than crashing" do expect(Unitsml::Unit.new("m", prefix: "").prefix).to be_nil end + + it "guards a Sqrt-wrapped dimension against mixing with a unit" do + expect { Unitsml::Unit.new("m") * Unitsml.parse("sqrt(dim_L)") } + .to raise_error(Unitsml::Errors::MixedTermsError) + end + + it "emits no Quantity for an explicit but unresolvable quantity" do + xml = Unitsml.compose(units: ["Hz"], quantity: "bogus_qty").to_xml + expect(xml).not_to include(" Date: Fri, 3 Jul 2026 20:03:32 +0500 Subject: [PATCH 03/18] feat(compose): add fluent chaining builder (#unit/#dimension) --- docs/README.adoc | 10 ++++++ lib/unitsml/compose/builder.rb | 19 +++++++++++ lib/unitsml/compose/composable.rb | 31 +++++++++++++++++- spec/unitsml/composite_spec.rb | 53 +++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/README.adoc b/docs/README.adoc index 2245a6f..e59d6bc 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -177,6 +177,16 @@ Unitsml::Unit.new("m") / Unitsml::Unit.new("s", 2) Unitsml::Unit.new("m", prefix: "k") # km ---- +The same composition can be written as a fluent chain — `#unit`/`#dimension` are +sugar over `*` (`unit("m", -1)` == `* Unit.new("m", -1)`), and +`#quantity`/`#name`/`#multiplier` attach metadata. Metadata must come last, since +each chained term builds a fresh `Formula`: + +[source,ruby] +---- +Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1).quantity("radiance") +---- + A unit is referenced by its symbol id (as in a parsed string, e.g. `"W"`) or by its `short` name (e.g. `"watt"`). Dimensions compose the same way (`Unitsml::Dimension.new("dim_L")`), but units and dimensions cannot be mixed in diff --git a/lib/unitsml/compose/builder.rb b/lib/unitsml/compose/builder.rb index 854be8f..a3f43cf 100644 --- a/lib/unitsml/compose/builder.rb +++ b/lib/unitsml/compose/builder.rb @@ -26,8 +26,27 @@ def from_terms(operands, quantity: nil, name: nil, multiplier: nil) build_root_formula(terms, build_metadata(quantity, name, multiplier)) end + # Return a Formula carrying the given render metadata (quantity/name/ + # multiplier). A Formula gets a copy with merged metadata; a lone + # Unit/Dimension is wrapped into a root Formula. Backs the fluent + # #quantity/#name/#multiplier chain methods. + def attach_metadata(node, **extras) + return from_terms([node], **extras) unless node.is_a?(Formula) + + added = build_metadata(extras[:quantity], extras[:name], + extras[:multiplier]) + copy = node.dup + copy.explicit_value = merge_metadata(node.explicit_value, added) + copy + end + private + def merge_metadata(existing, added) + merged = (existing || {}).merge(added || {}) + merged.empty? ? nil : merged + end + def build_root_formula(terms, metadata = nil) reject_mixed_terms!(terms) text = terms_text(terms) diff --git a/lib/unitsml/compose/composable.rb b/lib/unitsml/compose/composable.rb index 2f76a63..33fb1c7 100644 --- a/lib/unitsml/compose/composable.rb +++ b/lib/unitsml/compose/composable.rb @@ -4,7 +4,9 @@ module Unitsml module Compose # Multiplicative composition mixin for units, dimensions and formulas. `*` # combines two operands; `/` combines with the right-hand side inverted. - # Both are non-mutating and return a fresh root Formula (assembly lives in + # The fluent aliases (#unit/#dimension) chain the same way, and + # #quantity/#name/#multiplier attach render metadata. Everything is + # non-mutating and returns a fresh root Formula (assembly lives in # Compose::Builder). Included by Unit, Dimension and Formula only. module Composable def *(other) @@ -15,6 +17,33 @@ def /(other) build_formula(other, "/") end + # Fluent sugar over `*`: `unit("m", -1)` == `self * Unit.new("m", -1)`, + # so a chain reads like the operator DSL, e.g. + # Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1) + def unit(reference, power = nil, prefix: nil) + self * Unit.new(reference, power, prefix: prefix) + end + + def dimension(reference, power = nil) + self * Dimension.new(reference, power) + end + + # Attach render metadata. These come AFTER the units/dimensions: each + # #unit/#dimension (like `*`) builds a fresh root Formula and does not + # carry earlier metadata forward, so metadata set before another term is + # dropped. + def quantity(value) + Builder.attach_metadata(self, quantity: value) + end + + def name(value) + Builder.attach_metadata(self, name: value) + end + + def multiplier(value) + Builder.attach_metadata(self, multiplier: value) + end + # The term list this operand contributes to a composition. A leaf # (Unit/Dimension) contributes itself; Formula overrides this to # contribute its already-interleaved value. Public so an operand can diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 7dc15ce..7b3e1b2 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -291,6 +291,59 @@ def latex_for(power) end end + describe "fluent chain (#unit / #dimension / metadata)" do + it "chains units like the keyword form" do + chained = Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1) + expect(chained.to_xml).to eq(Unitsml.parse("W*m^-1*sr^-1").to_xml) + end + + it "matches the keyword form with metadata last" do + base = Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1) + chained = base.quantity("radiance") + keyword = Unitsml.compose( + units: ["W", { unit: "m", power: -1 }, { unit: "sr", power: -1 }], + quantity: "radiance", + ) + expect(chained.to_xml).to eq(keyword.to_xml) + end + + it "accepts a prefix in a chained unit" do + chained = Unitsml::Unit.new("W").unit("m", prefix: "k") + keyword = Unitsml.compose(units: ["W", { unit: "m", prefix: "k" }]) + expect(chained.to_xml).to eq(keyword.to_xml) + end + + it "chains dimensions" do + chained = Unitsml::Dimension.new("dim_L").dimension("dim_M") + keyword = Unitsml.compose(dimensions: ["dim_L", "dim_M"]) + expect(chained.to_xml).to eq(keyword.to_xml) + end + + it "guards a units/dimensions mix in the chain" do + expect { Unitsml::Unit.new("W").dimension("dim_L") } + .to raise_error(Unitsml::Errors::MixedTermsError) + end + + it "attaches metadata to a single unit" do + chained = Unitsml::Unit.new("W").quantity("radiance") + keyword = Unitsml.compose(units: ["W"], quantity: "radiance") + expect(chained).to be_a(Unitsml::Formula) + expect(chained.to_xml).to eq(keyword.to_xml) + end + + it "does not mutate the starting operand" do + w = Unitsml::Unit.new("W") + w.unit("m", -1) + expect(w).to eq(Unitsml::Unit.new("W")) + end + + it "drops metadata set before a later unit (metadata-last rule)" do + dropped = Unitsml::Unit.new("W").quantity("radiance").unit("m", -1) + plain = Unitsml::Unit.new("W").unit("m", -1) + expect(dropped.to_xml).to eq(plain.to_xml) + end + end + describe "regressions" do it "does not break parsing of da-/h-prefixed derived units" do expect { Unitsml.parse("hPa").to_xml }.not_to raise_error From 735cd5d38c6a3df0d1310224975b3765b5d74026 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 3 Jul 2026 20:16:23 +0500 Subject: [PATCH 04/18] fix(compose): validate the fluent #dimension reference --- lib/unitsml/compose.rb | 12 ++++++++++++ lib/unitsml/compose/composable.rb | 2 +- lib/unitsml/compose/composite.rb | 8 +++----- spec/unitsml/composite_spec.rb | 5 +++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb index f64525f..c0f9b89 100644 --- a/lib/unitsml/compose.rb +++ b/lib/unitsml/compose.rb @@ -10,5 +10,17 @@ module Compose autoload :Composable, "unitsml/compose/composable" autoload :Composite, "unitsml/compose/composite" autoload :TermTree, "unitsml/compose/term_tree" + + module_function + + # Validate a dimension reference (Dimension.new does not), so the keyword + # form and the fluent #dimension chain both fail fast with the same + # Errors::UnknownDimensionError instead of crashing at render. + def dimension_ref(reference) + string = reference.to_s + return string if Unitsdb.dimensions.parsables.key?(string) + + raise Errors::UnknownDimensionError.new(value: reference) + end end end diff --git a/lib/unitsml/compose/composable.rb b/lib/unitsml/compose/composable.rb index 33fb1c7..27136c0 100644 --- a/lib/unitsml/compose/composable.rb +++ b/lib/unitsml/compose/composable.rb @@ -25,7 +25,7 @@ def unit(reference, power = nil, prefix: nil) end def dimension(reference, power = nil) - self * Dimension.new(reference, power) + self * Dimension.new(Compose.dimension_ref(reference), power) end # Attach render metadata. These come AFTER the units/dimensions: each diff --git a/lib/unitsml/compose/composite.rb b/lib/unitsml/compose/composite.rb index 4723733..630ec9b 100644 --- a/lib/unitsml/compose/composite.rb +++ b/lib/unitsml/compose/composite.rb @@ -122,12 +122,10 @@ def require_reference(reference) end # A dimension reference is validated eagerly (Dimension.new does not), - # keeping compose fail-fast like the parser. + # keeping compose fail-fast like the parser. Shared with the fluent + # #dimension chain via Compose.dimension_ref. def require_dimension(reference) - string = reference.to_s - return string if Unitsdb.dimensions.parsables.key?(string) - - raise Errors::UnknownDimensionError.new(value: reference) + Compose.dimension_ref(reference) end def blank_reference diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 7b3e1b2..37bc1f4 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -324,6 +324,11 @@ def latex_for(power) .to raise_error(Unitsml::Errors::MixedTermsError) end + it "fails fast (BaseError) on an unknown dimension in the chain" do + expect { Unitsml::Dimension.new("dim_L").dimension("dim_bogus") } + .to raise_error(Unitsml::Errors::UnknownDimensionError) + end + it "attaches metadata to a single unit" do chained = Unitsml::Unit.new("W").quantity("radiance") keyword = Unitsml.compose(units: ["W"], quantity: "radiance") From 8fbb0d4212c1e5d17890460d02e50e22f7f483db Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 3 Jul 2026 21:25:15 +0500 Subject: [PATCH 05/18] fix(compose): validate fluent-chain ref/power/prefix/multiplier --- lib/unitsml/compose.rb | 37 +++++++++++++++++-- lib/unitsml/compose/builder.rb | 12 ++++++ lib/unitsml/compose/composable.rb | 6 ++- lib/unitsml/compose/composite.rb | 37 +++++-------------- .../errors/invalid_unit_entry_error.rb | 3 ++ spec/unitsml/composite_spec.rb | 28 ++++++++++++++ 6 files changed, 89 insertions(+), 34 deletions(-) diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb index c0f9b89..b362650 100644 --- a/lib/unitsml/compose.rb +++ b/lib/unitsml/compose.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true module Unitsml - # Programmatic composite-unit construction: the operator DSL (Composable) and - # the Unitsml.compose keyword form (Composite), both assembled by Builder into - # a plain root Formula. Kept in its own namespace so the composition machinery - # does not leak into the core Unit/Dimension/Formula classes. + # Programmatic composite-unit construction: the operator DSL / fluent chain + # (Composable), the Unitsml.compose keyword form (Composite), and the shared + # assembly (Builder). The raw-input validators below are shared by BOTH the + # keyword form and the fluent chain so a bad reference/power/prefix fails fast + # with the same Errors::* class no matter which surface is used. module Compose autoload :Builder, "unitsml/compose/builder" autoload :Composable, "unitsml/compose/composable" @@ -13,6 +14,16 @@ module Compose module_function + # A unit reference just needs to be non-blank; Unit.new then fail-fasts on + # an unresolvable (or dim_*) name. Rejects nil/"" (which Unit.new would let + # through as its internal empty sentinel and crash at render). + def unit_ref(reference) + string = reference.to_s + return string unless string.strip.empty? + + raise Errors::UnknownUnitError.new(value: reference) + end + # Validate a dimension reference (Dimension.new does not), so the keyword # form and the fluent #dimension chain both fail fast with the same # Errors::UnknownDimensionError instead of crashing at render. @@ -22,5 +33,23 @@ def dimension_ref(reference) raise Errors::UnknownDimensionError.new(value: reference) end + + # A prefix supplied as a Prefix object is reduced to its name so Unit.new + # validates it; other values (String/Symbol/nil) pass through. + def prefix_ref(prefix) + prefix.is_a?(Prefix) ? prefix.prefix_name : prefix + end + + # A Number power must still be a parser-valid exponent (integer or n/m, + # optionally signed / double-slashed); a decimal ("0.5") or garbage ("abc") + # has no parsed equivalent and is rejected. Other types (Integer/Rational/ + # Float/nil) are validated by Builder during assembly. + def power(value) + return value unless value.is_a?(Number) + return value if value.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z}) + + raise Errors::InvalidPowerError.new(value: value, + reason: :non_integer_float) + end end end diff --git a/lib/unitsml/compose/builder.rb b/lib/unitsml/compose/builder.rb index a3f43cf..8d70d2c 100644 --- a/lib/unitsml/compose/builder.rb +++ b/lib/unitsml/compose/builder.rb @@ -171,11 +171,23 @@ def interleave(operands) end def build_metadata(quantity, name, multiplier) + validate_multiplier!(multiplier) metadata = { quantity: quantity, name: name, multiplier: multiplier }.compact metadata.empty? ? nil : metadata end + # The multiplier is a render separator: nil, a String, or :space/ + # :nospace. Reject anything else at the compose boundary so a bad value + # fails as an Errors::* instead of leaking at render. + def validate_multiplier!(multiplier) + return if multiplier.nil? || multiplier.is_a?(String) + return if %i[space nospace].include?(multiplier) + + raise Errors::InvalidUnitEntryError.new(value: multiplier, + field: :multiplier) + end + def mul_extender Extender.new("*") end diff --git a/lib/unitsml/compose/composable.rb b/lib/unitsml/compose/composable.rb index 27136c0..c00290b 100644 --- a/lib/unitsml/compose/composable.rb +++ b/lib/unitsml/compose/composable.rb @@ -21,11 +21,13 @@ def /(other) # so a chain reads like the operator DSL, e.g. # Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1) def unit(reference, power = nil, prefix: nil) - self * Unit.new(reference, power, prefix: prefix) + self * Unit.new(Compose.unit_ref(reference), Compose.power(power), + prefix: Compose.prefix_ref(prefix)) end def dimension(reference, power = nil) - self * Dimension.new(Compose.dimension_ref(reference), power) + self * Dimension.new(Compose.dimension_ref(reference), + Compose.power(power)) end # Attach render metadata. These come AFTER the units/dimensions: each diff --git a/lib/unitsml/compose/composite.rb b/lib/unitsml/compose/composite.rb index 630ec9b..d9245bb 100644 --- a/lib/unitsml/compose/composite.rb +++ b/lib/unitsml/compose/composite.rb @@ -112,45 +112,26 @@ def build_reference(reference) Dimension.new(require_dimension(reference)) end - # A unit reference just needs to be non-blank; Unit.new fail-fasts on an - # unresolvable (or dim_*) name, which is what makes units: units-only. + # Reference/power/prefix validation is shared with the fluent chain via + # the Compose.* validators, so both surfaces reject the same bad input. def require_reference(reference) - string = reference.to_s - return string unless string.strip.empty? - - raise Errors::UnknownUnitError.new(value: reference) + Compose.unit_ref(reference) end - # A dimension reference is validated eagerly (Dimension.new does not), - # keeping compose fail-fast like the parser. Shared with the fluent - # #dimension chain via Compose.dimension_ref. def require_dimension(reference) Compose.dimension_ref(reference) end - def blank_reference - @kind == :dimension ? require_dimension(nil) : require_reference(nil) - end - - # A user-supplied power wrapped in a Number must still be a parser-valid - # exponent. This regex mirrors the grammar's `slashed_number` - # (parse.rb: a signed integer, optionally `/` or `//` then a signed - # integer), so forms like "1/-2" and "1//2" are accepted while a decimal - # ("0.5") or non-numeric string has no parsed equivalent and is rejected. - # Other power types are validated by Builder during assembly. def require_power(power) - return power unless power.is_a?(Number) - return power if power.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z}) - - raise Errors::InvalidPowerError.new(value: power, - reason: :non_integer_float) + Compose.power(power) end - # A prefix passed as a Prefix object is reduced to its name so Unit.new - # validates it (a bad name raises UnknownPrefixError); an unvalidated - # Prefix object would otherwise leak a NoMethodError at render. def prefix_ref(prefix) - prefix.is_a?(Prefix) ? prefix.prefix_name : prefix + Compose.prefix_ref(prefix) + end + + def blank_reference + @kind == :dimension ? require_dimension(nil) : require_reference(nil) end def expected_class diff --git a/lib/unitsml/errors/invalid_unit_entry_error.rb b/lib/unitsml/errors/invalid_unit_entry_error.rb index c3d867d..6fb2098 100644 --- a/lib/unitsml/errors/invalid_unit_entry_error.rb +++ b/lib/unitsml/errors/invalid_unit_entry_error.rb @@ -24,6 +24,9 @@ def message_for(field, value) "Unit, Dimension or Formula." when :prefix "[unitsml] A dimension entry cannot take a prefix: #{value.inspect}." + when :multiplier + "[unitsml] Invalid multiplier: #{value.inspect} — expected a " \ + "String, :space, or :nospace." else "[unitsml] Invalid #{field} entry: #{value.inspect} — expected a " \ "reference (String/Symbol), a Hash, or a matching Unit/Dimension." diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 37bc1f4..bad5998 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -329,6 +329,34 @@ def latex_for(power) .to raise_error(Unitsml::Errors::UnknownDimensionError) end + it "validates a blank/nil ref in the chain like the keyword form" do + w = Unitsml::Unit.new("W") + expect { w.unit(nil) }.to raise_error(Unitsml::Errors::UnknownUnitError) + expect { w.unit("") }.to raise_error(Unitsml::Errors::UnknownUnitError) + end + + it "validates power and prefix in a chained unit" do + w = Unitsml::Unit.new("W") + expect { w.unit("m", Unitsml::Number.new("abc")) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + expect { w.unit("m", prefix: Unitsml::Prefix.new("zz")) } + .to raise_error(Unitsml::Errors::UnknownPrefixError) + end + + it "rejects a non-String/Symbol multiplier as a BaseError" do + expect { Unitsml::Unit.new("W").unit("m").multiplier({ x: 1 }) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml.compose(units: ["W"], multiplier: { x: 1 }) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "still accepts a valid multiplier" do + expect { Unitsml::Unit.new("W").unit("m").multiplier("·") } + .not_to raise_error + expect { Unitsml::Unit.new("W").unit("m").multiplier(:nospace) } + .not_to raise_error + end + it "attaches metadata to a single unit" do chained = Unitsml::Unit.new("W").quantity("radiance") keyword = Unitsml.compose(units: ["W"], quantity: "radiance") From 574b8f3d674a5965901b020c7dafa8038c9abf71 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 3 Jul 2026 21:37:10 +0500 Subject: [PATCH 06/18] fix(compose): validate the name metadata like multiplier --- lib/unitsml/compose/builder.rb | 10 ++++++++++ lib/unitsml/errors/invalid_unit_entry_error.rb | 3 +++ spec/unitsml/composite_spec.rb | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/lib/unitsml/compose/builder.rb b/lib/unitsml/compose/builder.rb index 8d70d2c..d64ba8e 100644 --- a/lib/unitsml/compose/builder.rb +++ b/lib/unitsml/compose/builder.rb @@ -171,12 +171,22 @@ def interleave(operands) end def build_metadata(quantity, name, multiplier) + validate_name!(name) validate_multiplier!(multiplier) metadata = { quantity: quantity, name: name, multiplier: multiplier }.compact metadata.empty? ? nil : metadata end + # name is embedded directly into , so it must be a plain + # String/Symbol; a Hash/other would serialize as garbage. (quantity is + # resolved and dropped silently when unresolvable, so needs no guard.) + def validate_name!(name) + return if name.nil? || name.is_a?(String) || name.is_a?(Symbol) + + raise Errors::InvalidUnitEntryError.new(value: name, field: :name) + end + # The multiplier is a render separator: nil, a String, or :space/ # :nospace. Reject anything else at the compose boundary so a bad value # fails as an Errors::* instead of leaking at render. diff --git a/lib/unitsml/errors/invalid_unit_entry_error.rb b/lib/unitsml/errors/invalid_unit_entry_error.rb index 6fb2098..2251e5e 100644 --- a/lib/unitsml/errors/invalid_unit_entry_error.rb +++ b/lib/unitsml/errors/invalid_unit_entry_error.rb @@ -27,6 +27,9 @@ def message_for(field, value) when :multiplier "[unitsml] Invalid multiplier: #{value.inspect} — expected a " \ "String, :space, or :nospace." + when :name + "[unitsml] Invalid name: #{value.inspect} — expected a String " \ + "or Symbol." else "[unitsml] Invalid #{field} entry: #{value.inspect} — expected a " \ "reference (String/Symbol), a Hash, or a matching Unit/Dimension." diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index bad5998..cb495a8 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -350,6 +350,13 @@ def latex_for(power) .to raise_error(Unitsml::Errors::BaseError) end + it "rejects a non-String/Symbol name as a BaseError" do + expect { Unitsml::Unit.new("W").name({ x: 1 }) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml.compose(units: ["W"], name: { x: 1 }) } + .to raise_error(Unitsml::Errors::BaseError) + end + it "still accepts a valid multiplier" do expect { Unitsml::Unit.new("W").unit("m").multiplier("·") } .not_to raise_error From 9358a51480dd2efe94644cca155e92178e9eedda Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 3 Jul 2026 22:17:58 +0500 Subject: [PATCH 07/18] docs: note fluent-chain + metadata validation in compose section --- docs/README.adoc | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/README.adoc b/docs/README.adoc index e59d6bc..dc69f4a 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -219,14 +219,14 @@ formula = Unitsml.compose(units: ["W", { unit: "m", power: -1 }]) formula.to_xml(quantity: "radiance") # emits a element ---- -`Unitsml.compose` validates every reference, power and prefix it is given and -raises a `Unitsml::Errors::*` (all subclasses of `Unitsml::Errors::BaseError`) -for anything it cannot resolve: an unknown unit, dimension or prefix, a -units/dimensions mix, an empty composition, or a power the parser cannot express -(a decimal exponent — use a `Rational`). The operator DSL composes the objects -you construct, so — as with rendering a `Unit` or `Dimension` directly — a -hand-built object carrying an unresolvable prefix or dimension name is the -caller's responsibility. +`Unitsml.compose` and the fluent chain validate every reference, power, prefix +and metadata value and raise a `Unitsml::Errors::*` (all subclasses of +`Unitsml::Errors::BaseError`) for anything they cannot resolve: an unknown unit, +dimension or prefix, a units/dimensions mix, an empty composition, a power the +parser cannot express (a decimal exponent — use a `Rational`), or a non-string +`name`/`multiplier`. The operator DSL composes the objects you construct, so — +as with rendering a `Unit` or `Dimension` directly — a hand-built object +carrying an unresolvable prefix or dimension name is the caller's responsibility. === Format representation From ae3d2f05750b5d944a6eb54cb41581d68b6a2d88 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 3 Jul 2026 22:24:44 +0500 Subject: [PATCH 08/18] refactor(compose): resolve units by symbol id only (drop short names) --- docs/README.adoc | 9 ++++----- lib/unitsml/errors/unknown_unit_error.rb | 2 +- lib/unitsml/unit.rb | 11 ++++------- lib/unitsml/unitsdb/units.rb | 10 ---------- spec/unitsml/composite_spec.rb | 5 +++-- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/docs/README.adoc b/docs/README.adoc index dc69f4a..c66016e 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -187,16 +187,15 @@ each chained term builds a fresh `Formula`: Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1).quantity("radiance") ---- -A unit is referenced by its symbol id (as in a parsed string, e.g. `"W"`) or by -its `short` name (e.g. `"watt"`). Dimensions compose the same way +A unit is referenced by its symbol id, exactly as in a parsed string +(e.g. `"W"`). Dimensions compose the same way (`Unitsml::Dimension.new("dim_L")`), but units and dimensions cannot be mixed in one expression. The `Unitsml.compose` keyword form builds the same `Formula` from a list. Pass either `units:` or `dimensions:` (not both). A `units:` entry is a symbol id, a -`short` name, a `{ unit:, power:, prefix: }` hash, or a pre-built -`Unitsml::Unit`; a `dimensions:` entry is a dimension id or a -`{ dimension:, power: }` hash: +`{ unit:, power:, prefix: }` hash, or a pre-built `Unitsml::Unit`; a +`dimensions:` entry is a dimension id or a `{ dimension:, power: }` hash: [source,ruby] ---- diff --git a/lib/unitsml/errors/unknown_unit_error.rb b/lib/unitsml/errors/unknown_unit_error.rb index c1e3149..d78c7f5 100644 --- a/lib/unitsml/errors/unknown_unit_error.rb +++ b/lib/unitsml/errors/unknown_unit_error.rb @@ -9,7 +9,7 @@ def initialize(value:, field: :unit) @value = value @field = field super("[unitsml] Unknown unit reference: #{value.inspect} — expected " \ - "a symbol id (e.g. \"W\") or short name (e.g. \"watt\").") + "a unit symbol id (e.g. \"W\").") end end end diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index f524e18..a15e796 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -123,18 +123,15 @@ def inverse_power_numerator private - # Resolve a unit reference to a canonical symbol id. Tries the symbol id - # first (parse-consistent), then the unique `short` slug. The empty string - # and the UNKNOWN sentinel are passed through untouched (internal callers - # rely on them). Raises for anything unresolvable. + # Resolve a unit reference to a canonical symbol id, exactly like the parser + # (symbol ids only — no short-name resolution). The empty string and the + # UNKNOWN sentinel are passed through untouched (internal callers rely on + # them). Raises for anything unresolvable. def resolve_ref(ref) ref = ref.to_s return ref if ref.empty? || ref == Utility::UNKNOWN return ref if Unitsdb.units.find_by_symbol_id(ref) - short_unit = Unitsdb.units.find_by_short(ref) - return short_unit.symbols.first.id if short_unit - raise Errors::UnknownUnitError.new(value: ref) end diff --git a/lib/unitsml/unitsdb/units.rb b/lib/unitsml/unitsdb/units.rb index 7b829b6..dddcc0d 100644 --- a/lib/unitsml/unitsdb/units.rb +++ b/lib/unitsml/unitsdb/units.rb @@ -32,16 +32,6 @@ def symbols_hash unit.symbols&.each { |unit_sym| object[unit_sym.id] = unit } end end - - def find_by_short(short) - short_hash[short] - end - - def short_hash - @short_hash ||= units.each_with_object({}) do |unit, object| - object[unit.short] = unit if unit.short - end - end end Configuration.register_model(Units, id: :units) diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index cb495a8..eaaa3db 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -155,8 +155,9 @@ def unit(name, power = nil, prefix: nil) end describe "unit references" do - it "resolves a short name to the canonical symbol id" do - expect(Unitsml::Unit.new("watt")).to eq(Unitsml::Unit.new("W")) + it "rejects a short name (symbol ids only, like the parser)" do + expect { Unitsml::Unit.new("watt") } + .to raise_error(Unitsml::Errors::UnknownUnitError) end it "accepts a symbol reference" do From e680e72f96517bac71a9f122847b7a6607721175 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 3 Jul 2026 22:42:07 +0500 Subject: [PATCH 09/18] docs(compose): document the default * separator and multiplier: control --- docs/README.adoc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/README.adoc b/docs/README.adoc index c66016e..2fb334b 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -218,6 +218,19 @@ formula = Unitsml.compose(units: ["W", { unit: "m", power: -1 }]) formula.to_xml(quantity: "radiance") # emits a element ---- +Composed terms are always joined with `*` (division becomes a negative +exponent), so a composed `Formula` renders like `W*m^-1`, not `W/m`. The +separator is a display choice — pass the `multiplier:` argument to any `to_*` +method (`:space`, `:nospace`, or a custom string such as `"·"`) to change it: + +[source,ruby] +---- +formula = Unitsml::Unit.new("W").unit("m", -1) +formula.to_asciimath # => "W*m^-1" +formula.to_asciimath(multiplier: :space) # => "W m^-1" +formula.to_asciimath(multiplier: "·") # => "W·m^-1" +---- + `Unitsml.compose` and the fluent chain validate every reference, power, prefix and metadata value and raise a `Unitsml::Errors::*` (all subclasses of `Unitsml::Errors::BaseError`) for anything they cannot resolve: an unknown unit, From bf10d02e5dafb8047180d6b4b3d4d92ad2d4bae5 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Mon, 6 Jul 2026 14:02:55 +0500 Subject: [PATCH 10/18] fix(compose): address Copilot review findings --- lib/unitsml/compose.rb | 2 +- lib/unitsml/errors/invalid_power_error.rb | 3 +++ lib/unitsml/unit.rb | 5 ++++- lib/unitsml/utility.rb | 13 +++++++++++-- spec/unitsml/composite_spec.rb | 23 +++++++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb index b362650..e79429e 100644 --- a/lib/unitsml/compose.rb +++ b/lib/unitsml/compose.rb @@ -49,7 +49,7 @@ def power(value) return value if value.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z}) raise Errors::InvalidPowerError.new(value: value, - reason: :non_integer_float) + reason: :invalid_number) end end end diff --git a/lib/unitsml/errors/invalid_power_error.rb b/lib/unitsml/errors/invalid_power_error.rb index 4b5f941..c986bc1 100644 --- a/lib/unitsml/errors/invalid_power_error.rb +++ b/lib/unitsml/errors/invalid_power_error.rb @@ -21,6 +21,9 @@ def message_for(reason, value) when :non_integer_float "[unitsml] Non-integer Float power: #{value.inspect} — use a " \ "Rational (e.g. Rational(1, 2)) for a fractional exponent." + when :invalid_number + "[unitsml] Invalid Number power: #{value.inspect} — a Number " \ + "exponent must hold an integer or n/m fraction (e.g. \"2\", \"1/2\")." else "[unitsml] Unsupported power: #{value.inspect} — expected an " \ "Integer, Rational, Float or Unitsml::Number." diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index a15e796..0d461f4 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -126,8 +126,11 @@ def inverse_power_numerator # Resolve a unit reference to a canonical symbol id, exactly like the parser # (symbol ids only — no short-name resolution). The empty string and the # UNKNOWN sentinel are passed through untouched (internal callers rely on - # them). Raises for anything unresolvable. + # them); nil is not a sentinel and fails fast rather than silently building + # a broken unit. Raises for anything unresolvable. def resolve_ref(ref) + raise Errors::UnknownUnitError.new(value: ref) if ref.nil? + ref = ref.to_s return ref if ref.empty? || ref == Utility::UNKNOWN return ref if Unitsdb.units.find_by_symbol_id(ref) diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index e65efb9..0326b50 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -380,8 +380,17 @@ def quantity(normtext, instance, dims = nil) return if instance && record.nil? id = canonical_nist_id(record) || unit.quantity_references&.first&.id - url = unit ? "##{unit_dimension_id(unit)}" : "##{dim_id(dims)}" - model_quantity_xml(id, url, record&.quantity_type) + model_quantity_xml(id, quantity_dimension_url(unit, dims), + record&.quantity_type) + end + + # The Quantity's dimensionURL, or nil when the dimension id cannot be + # determined (e.g. a composite whose decomposition hits the UNKNOWN + # sentinel) — the attribute is then omitted rather than emitting a + # broken "#" pointer, keeping the rest of the Quantity data intact. + def quantity_dimension_url(unit, dims) + ref = unit ? unit_dimension_id(unit) : dim_id(dims) + "##{ref}" if ref end def canonical_nist_id(record) diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index eaaa3db..c8e394f 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -160,6 +160,12 @@ def unit(name, power = nil, prefix: nil) .to raise_error(Unitsml::Errors::UnknownUnitError) end + it "fails fast on a nil reference (nil is not the internal sentinel)" do + expect { Unitsml::Unit.new(nil) } + .to raise_error(Unitsml::Errors::UnknownUnitError) + expect { Unitsml::Unit.new("") }.not_to raise_error + end + it "accepts a symbol reference" do expect(Unitsml::Unit.new(:W).unit_name).to eq("W") end @@ -447,5 +453,22 @@ def latex_for(power) xml = Unitsml.compose(units: ["Hz"], quantity: "bogus_qty").to_xml expect(xml).not_to include(" Date: Mon, 6 Jul 2026 16:45:25 +0500 Subject: [PATCH 11/18] feat(compose): validate power_numerator type in Unit and Dimension --- lib/unitsml.rb | 1 + lib/unitsml/dimension.rb | 5 +++-- lib/unitsml/opal.rb | 3 +++ lib/unitsml/power_numerator.rb | 27 +++++++++++++++++++++++++++ lib/unitsml/unit.rb | 5 +++-- spec/unitsml/composite_spec.rb | 29 +++++++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 lib/unitsml/power_numerator.rb diff --git a/lib/unitsml.rb b/lib/unitsml.rb index 3a47762..f510d7f 100644 --- a/lib/unitsml.rb +++ b/lib/unitsml.rb @@ -21,6 +21,7 @@ module Unitsml autoload :Number, "unitsml/number" autoload :Parse, "unitsml/parse" autoload :Parser, "unitsml/parser" + autoload :PowerNumerator, "unitsml/power_numerator" autoload :Prefix, "unitsml/prefix" autoload :PrefixAdapter, "unitsml/prefix_adapter" autoload :Sqrt, "unitsml/sqrt" diff --git a/lib/unitsml/dimension.rb b/lib/unitsml/dimension.rb index 635b591..7a71c15 100644 --- a/lib/unitsml/dimension.rb +++ b/lib/unitsml/dimension.rb @@ -4,12 +4,13 @@ module Unitsml class Dimension include MathmlHelper include Compose::Composable + include PowerNumerator - attr_accessor :dimension_name, :power_numerator + attr_accessor :dimension_name def initialize(dimension_name, power_numerator = nil) @dimension_name = dimension_name - @power_numerator = power_numerator + self.power_numerator = power_numerator end def ==(other) diff --git a/lib/unitsml/opal.rb b/lib/unitsml/opal.rb index e2b9eb1..3a4bb6c 100644 --- a/lib/unitsml/opal.rb +++ b/lib/unitsml/opal.rb @@ -94,6 +94,9 @@ require "unitsml/compose/builder" require "unitsml/compose/composable" require "unitsml/compose/composite" +# PowerNumerator must be defined before Unit/Dimension include it (no lazy +# autoload under Opal). +require "unitsml/power_numerator" require "unitsml/dimension" require "unitsml/extender" require "unitsml/fenced" diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb new file mode 100644 index 0000000..20db164 --- /dev/null +++ b/lib/unitsml/power_numerator.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Unitsml + # Validated storage for the power_numerator exponent shared by Unit and + # Dimension: only types the render/decomposition paths can handle may be + # stored — nil, a raw Numeric (internal decomposition), a Number, or a + # Fenced exponent (the parser's m^((1/2)) form). Anything else raises + # rather than being stored and crashing later at render. Values are stored + # as given (never coerced): value-level rules such as "the parser cannot + # express a decimal exponent" belong to the compose input boundary. + module PowerNumerator + attr_reader :power_numerator + + def power_numerator=(power) + @power_numerator = validate_power_type(power) + end + + private + + def validate_power_type(power) + return power if power.nil? || power.is_a?(Numeric) + return power if power.is_a?(Number) || power.is_a?(Fenced) + + raise Errors::InvalidPowerError.new(value: power) + end + end +end diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index 0d461f4..df20507 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -4,8 +4,9 @@ module Unitsml class Unit include MathmlHelper include Compose::Composable + include PowerNumerator - attr_accessor :unit_name, :power_numerator, :prefix + attr_accessor :unit_name, :prefix SI_UNIT_SYSTEM = %w[si_base si_derived_special si_derived_non_special].freeze @@ -15,7 +16,7 @@ def initialize(unit_name, prefix: nil) @prefix = coerce_prefix(prefix) @unit_name = resolve_ref(unit_name) - @power_numerator = power_numerator + self.power_numerator = power_numerator end def ==(other) diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index c8e394f..b9efb97 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -165,6 +165,35 @@ def unit(name, power = nil, prefix: nil) .to raise_error(Unitsml::Errors::UnknownUnitError) expect { Unitsml::Unit.new("") }.not_to raise_error end + end + + describe "power type validation (Unit/Dimension storage)" do + it "rejects an unsupported power datatype at construction" do + expect { Unitsml::Unit.new("m", "2") } + .to raise_error(Unitsml::Errors::InvalidPowerError) + expect { Unitsml::Dimension.new("dim_L", "2") } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + it "rejects an unsupported power datatype via the setter" do + unit = Unitsml::Unit.new("m") + expect { unit.power_numerator = { x: 1 } } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + it "stores supported power types as given (no coercion)" do + expect(Unitsml::Unit.new("m", 2).power_numerator).to eq(2) + expect(Unitsml::Unit.new("m", Rational(1, 2)).power_numerator) + .to eq(Rational(1, 2)) + expect(Unitsml::Unit.new("m", 1.5).power_numerator).to eq(1.5) + number = Unitsml::Number.new("3") + expect(Unitsml::Unit.new("m", number).power_numerator).to be(number) + expect(Unitsml::Dimension.new("dim_L", 2).power_numerator).to eq(2) + end + + it "keeps the parser's Fenced exponent storable" do + expect { Unitsml.parse("m^((1/2))").to_xml }.not_to raise_error + end it "accepts a symbol reference" do expect(Unitsml::Unit.new(:W).unit_name).to eq("W") From f2e0b9debf803740b37ff74b05dc17e1bbfb2cc2 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Mon, 6 Jul 2026 18:52:42 +0500 Subject: [PATCH 12/18] fix(compose): storage-specific message for unsupported power types --- lib/unitsml/errors/invalid_power_error.rb | 3 +++ lib/unitsml/power_numerator.rb | 3 ++- spec/unitsml/composite_spec.rb | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/unitsml/errors/invalid_power_error.rb b/lib/unitsml/errors/invalid_power_error.rb index c986bc1..d8868e3 100644 --- a/lib/unitsml/errors/invalid_power_error.rb +++ b/lib/unitsml/errors/invalid_power_error.rb @@ -24,6 +24,9 @@ def message_for(reason, value) when :invalid_number "[unitsml] Invalid Number power: #{value.inspect} — a Number " \ "exponent must hold an integer or n/m fraction (e.g. \"2\", \"1/2\")." + when :unsupported_storage + "[unitsml] Cannot store #{value.inspect} as an exponent — " \ + "expected a Numeric, Unitsml::Number, or Unitsml::Fenced." else "[unitsml] Unsupported power: #{value.inspect} — expected an " \ "Integer, Rational, Float or Unitsml::Number." diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb index 20db164..325a4ee 100644 --- a/lib/unitsml/power_numerator.rb +++ b/lib/unitsml/power_numerator.rb @@ -21,7 +21,8 @@ def validate_power_type(power) return power if power.nil? || power.is_a?(Numeric) return power if power.is_a?(Number) || power.is_a?(Fenced) - raise Errors::InvalidPowerError.new(value: power) + raise Errors::InvalidPowerError.new(value: power, + reason: :unsupported_storage) end end end diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index b9efb97..9c22de4 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -170,7 +170,8 @@ def unit(name, power = nil, prefix: nil) describe "power type validation (Unit/Dimension storage)" do it "rejects an unsupported power datatype at construction" do expect { Unitsml::Unit.new("m", "2") } - .to raise_error(Unitsml::Errors::InvalidPowerError) + .to raise_error(Unitsml::Errors::InvalidPowerError, + /Cannot store .+ as an exponent/) expect { Unitsml::Dimension.new("dim_L", "2") } .to raise_error(Unitsml::Errors::InvalidPowerError) end From 3d5f80bc656980e1178ec20849a88dd4b9f7bb7b Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Tue, 7 Jul 2026 20:37:57 +0500 Subject: [PATCH 13/18] feat(compose): division keeps "/", explicit extenders, input hardening --- docs/README.adoc | 42 ++- lib/unitsml/compose.rb | 68 +++- lib/unitsml/compose/builder.rb | 41 +-- lib/unitsml/compose/composable.rb | 19 +- lib/unitsml/compose/composite.rb | 9 +- lib/unitsml/dimension.rb | 14 +- lib/unitsml/errors.rb | 2 + lib/unitsml/errors/base_error.rb | 11 + lib/unitsml/errors/invalid_power_error.rb | 8 +- .../errors/invalid_unit_entry_error.rb | 14 +- .../errors/misplaced_extender_error.rb | 18 ++ lib/unitsml/errors/unknown_dimension_error.rb | 2 +- lib/unitsml/errors/unknown_prefix_error.rb | 2 +- lib/unitsml/errors/unknown_unit_error.rb | 4 +- lib/unitsml/formula.rb | 51 +++ lib/unitsml/opal.rb | 1 + lib/unitsml/power_numerator.rb | 5 +- lib/unitsml/unit.rb | 17 +- lib/unitsml/utility.rb | 12 +- spec/unitsml/composite_spec.rb | 290 +++++++++++++++++- 20 files changed, 558 insertions(+), 72 deletions(-) create mode 100644 lib/unitsml/errors/misplaced_extender_error.rb diff --git a/docs/README.adoc b/docs/README.adoc index 2fb334b..a9a6c2a 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -163,14 +163,16 @@ When the parts are already available as data rather than a string, composite units can be built without parsing. Multiplication (`*`) and division (`/`) compose units, dimensions and formulas, -returning a `Unitsml::Formula`: +returning a `Unitsml::Formula`. Like the parser, `/` keeps the `"/"` separator +and inverts the right-hand operand's power, so a single division renders +identically to the parsed string (`W / m` == `Unitsml.parse("W/m")`): [source,ruby] ---- -# watt per metre per steradian; "/" inverts the right-hand operand +# watt per metre per steradian, rendered "W/m^-1/sr^-1" Unitsml::Unit.new("W") / Unitsml::Unit.new("m") / Unitsml::Unit.new("sr") -# m·s⁻²; powers come from the constructor's second argument +# m/s^2; powers come from the constructor's second argument Unitsml::Unit.new("m") / Unitsml::Unit.new("s", 2) # prefixes via the `prefix:` keyword @@ -187,6 +189,40 @@ each chained term builds a fresh `Formula`: Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1).quantity("radiance") ---- +An explicit separator can be placed between terms with `#extender` (alias +`#ext`), accepting exactly the parser's grammar set: `"*"`, `"/"` or `"//"` +(any other glyph is a display concern — use the `multiplier:` render option). +It is glyph-only: nothing is negated or transformed, so exponents are stated +explicitly, exactly as a parsed string stores them: + +[source,ruby] +---- +# byte-identical to Unitsml.parse("W/m") in every format +Unitsml::Unit.new("W").extender("/").unit("m", -1) + +# mixed separators, as in Unitsml.parse("W*m/W") +Unitsml::Unit.new("W").ext("*").unit("m").ext("/").unit("W", -1) +---- + +The glyph may be a String, a Symbol, or an `Unitsml::Extender` object; an +`Extender` also works directly as an operator operand: + +[source,ruby] +---- +Unitsml::Unit.new("W") * Unitsml::Extender.new("/") * Unitsml::Unit.new("m", -1) +---- + +A separator must sit between two operands. An expression left ending in a +separator, or with two separators together, has no valid rendering and raises +`Unitsml::Errors::MisplacedExtenderError` when a `to_*` method is called (the +intermediate chain value is still usable — only rendering it is rejected): + +[source,ruby] +---- +Unitsml::Unit.new("W").ext("/").to_latex # raises MisplacedExtenderError +Unitsml::Unit.new("W").ext("/").unit("m", -1).to_latex # ok — "W/m^-1" +---- + A unit is referenced by its symbol id, exactly as in a parsed string (e.g. `"W"`). Dimensions compose the same way (`Unitsml::Dimension.new("dim_L")`), but units and dimensions cannot be mixed in diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb index e79429e..5178c67 100644 --- a/lib/unitsml/compose.rb +++ b/lib/unitsml/compose.rb @@ -12,14 +12,49 @@ module Compose autoload :Composite, "unitsml/compose/composite" autoload :TermTree, "unitsml/compose/term_tree" + # The parser's grammar knows exactly these separators; anything else is a + # display concern served by the render-time multiplier: option. + EXTENDERS = ["*", "/", "//"].freeze + module_function + # A render name is embedded directly into , so it must be a plain + # String/Symbol (or nil); a Hash/other serializes as garbage. Shared by the + # compose build path and the render-option path (to_*(name:)). + def validate_name!(name) + return if type_any?([NilClass, String, Symbol], name) + + raise Errors::InvalidUnitEntryError.new(value: name, field: :name) + end + + # A multiplier is a render separator: nil, a String, or :space/:nospace. + # Reject anything else where it enters (compose metadata, or a + # to_*(multiplier:) option) so a bad value fails as Errors::*, not later. + def validate_multiplier!(multiplier) + return if type_any?([NilClass, String], multiplier) + return if %i[space nospace].include?(multiplier) + + raise Errors::InvalidUnitEntryError.new(value: multiplier, + field: :multiplier) + end + + # An explicit extender: an Extender object (reduced to its symbol), or a + # glyph the parser could have produced ("*", "/" or "//"). Custom separators + # belong to the multiplier: render option, not the Formula. + def extender_sym(value) + symbol = type?(Extender, value) ? value.symbol : value + string = safe_string(symbol) + return string if EXTENDERS.include?(string) + + raise Errors::InvalidUnitEntryError.new(value: symbol, field: :extender) + end + # A unit reference just needs to be non-blank; Unit.new then fail-fasts on # an unresolvable (or dim_*) name. Rejects nil/"" (which Unit.new would let # through as its internal empty sentinel and crash at render). def unit_ref(reference) - string = reference.to_s - return string unless string.strip.empty? + string = safe_string(reference) + return string if string && !string.strip.empty? raise Errors::UnknownUnitError.new(value: reference) end @@ -28,8 +63,8 @@ def unit_ref(reference) # form and the fluent #dimension chain both fail fast with the same # Errors::UnknownDimensionError instead of crashing at render. def dimension_ref(reference) - string = reference.to_s - return string if Unitsdb.dimensions.parsables.key?(string) + string = safe_string(reference) + return string if string && Unitsdb.dimensions.parsables.key?(string) raise Errors::UnknownDimensionError.new(value: reference) end @@ -37,7 +72,7 @@ def dimension_ref(reference) # A prefix supplied as a Prefix object is reduced to its name so Unit.new # validates it; other values (String/Symbol/nil) pass through. def prefix_ref(prefix) - prefix.is_a?(Prefix) ? prefix.prefix_name : prefix + type?(Prefix, prefix) ? prefix.prefix_name : prefix end # A Number power must still be a parser-valid exponent (integer or n/m, @@ -45,11 +80,32 @@ def prefix_ref(prefix) # has no parsed equivalent and is rejected. Other types (Integer/Rational/ # Float/nil) are validated by Builder during assembly. def power(value) - return value unless value.is_a?(Number) + return value unless type?(Number, value) return value if value.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z}) raise Errors::InvalidPowerError.new(value: value, reason: :invalid_number) end + + # #to_s for a raw compose input without letting a pathological override (one + # that raises, has no #to_s, or returns a non-String) escape as a + # non-Errors::* exception; the caller treats nil as "not usable". + def safe_string(value) + string = value.to_s + string if string.is_a?(String) + rescue StandardError + nil + end + + # Class-membership test via Module#=== rather than #is_a?, so a compose + # input that is a BasicObject (which has no #is_a?/#nil?) is rejected as a + # typed Errors::* instead of the check itself raising a raw NoMethodError. + def type?(klass, value) + klass === value # rubocop:disable Style/CaseEquality + end + + def type_any?(klasses, value) + klasses.any? { |klass| type?(klass, value) } + end end end diff --git a/lib/unitsml/compose/builder.rb b/lib/unitsml/compose/builder.rb index d64ba8e..4c3b7d8 100644 --- a/lib/unitsml/compose/builder.rb +++ b/lib/unitsml/compose/builder.rb @@ -11,12 +11,23 @@ module Compose # TermTree; leaf text lives on Unit/Dimension. module Builder class << self - # Combine a left term list with the right operand's terms, interleaving - # multiplication. `/` inverts every right-hand leaf's power. + # Combine a left term list with the right operand's terms. The operator + # glyph itself joins the two sides (like the parser: `a / b` keeps the + # "/" and inverts b's power; `a * b` keeps "*"). An explicit trailing + # extender on the left (from #extender) already is the join, so no glyph + # is inserted after it — but `/` still inverts the right-hand side. def build_product(left_terms, right_terms, operator) left = build_terms(left_terms, invert: false) right = build_terms(right_terms, invert: operator == "/") - build_root_formula(left + [mul_extender] + right) + joiner = left.last.is_a?(Extender) ? [] : [Extender.new(operator)] + build_root_formula(left + joiner + right) + end + + # Append an explicit (pre-validated) separator glyph to the term list, + # exactly as the parser stores it. Glyph-only: no transformation. + def append_extender(terms, symbol) + left = build_terms(terms, invert: false) + build_root_formula(left + [Extender.new(symbol)]) end # Build a root Formula from an ordered list of unit/dimension operands, @@ -171,33 +182,13 @@ def interleave(operands) end def build_metadata(quantity, name, multiplier) - validate_name!(name) - validate_multiplier!(multiplier) + Compose.validate_name!(name) + Compose.validate_multiplier!(multiplier) metadata = { quantity: quantity, name: name, multiplier: multiplier }.compact metadata.empty? ? nil : metadata end - # name is embedded directly into , so it must be a plain - # String/Symbol; a Hash/other would serialize as garbage. (quantity is - # resolved and dropped silently when unresolvable, so needs no guard.) - def validate_name!(name) - return if name.nil? || name.is_a?(String) || name.is_a?(Symbol) - - raise Errors::InvalidUnitEntryError.new(value: name, field: :name) - end - - # The multiplier is a render separator: nil, a String, or :space/ - # :nospace. Reject anything else at the compose boundary so a bad value - # fails as an Errors::* instead of leaking at render. - def validate_multiplier!(multiplier) - return if multiplier.nil? || multiplier.is_a?(String) - return if %i[space nospace].include?(multiplier) - - raise Errors::InvalidUnitEntryError.new(value: multiplier, - field: :multiplier) - end - def mul_extender Extender.new("*") end diff --git a/lib/unitsml/compose/composable.rb b/lib/unitsml/compose/composable.rb index c00290b..102f82f 100644 --- a/lib/unitsml/compose/composable.rb +++ b/lib/unitsml/compose/composable.rb @@ -30,6 +30,18 @@ def dimension(reference, power = nil) Compose.power(power)) end + # Append an explicit separator glyph ("*", "/" or "//") exactly as a + # parsed string would carry it. Glyph-only by design: nothing is negated + # or transformed — powers are always stated explicitly (the parser's + # division inference belongs to parsing, not to this builder). The next + # chained term joins without an implicit "*". To mirror parse("W/m"), + # write: unit_w.extender("/").unit("m", -1). + def extender(symbol) + Builder.append_extender(composable_terms, + Compose.extender_sym(symbol)) + end + alias ext extender + # Attach render metadata. These come AFTER the units/dimensions: each # #unit/#dimension (like `*`) builds a fresh root Formula and does not # carry earlier metadata forward, so metadata set before another term is @@ -57,6 +69,11 @@ def composable_terms private def build_formula(other, operator) + # An Extender operand is an explicit separator, not a term to combine: + # `w * Extender.new("/")` appends the glyph just like `w.extender("/")` + # (the operator itself is irrelevant — the Extender carries the glyph). + return extender(other) if Compose.type?(Extender, other) + Builder.build_product(composable_terms, operand_terms(other), operator) end @@ -69,7 +86,7 @@ def operand_terms(other) end def composable?(other) - other.is_a?(Unit) || other.is_a?(Dimension) || other.is_a?(Formula) + Compose.type_any?([Unit, Dimension, Formula], other) end end end diff --git a/lib/unitsml/compose/composite.rb b/lib/unitsml/compose/composite.rb index d9245bb..dddc707 100644 --- a/lib/unitsml/compose/composite.rb +++ b/lib/unitsml/compose/composite.rb @@ -25,11 +25,14 @@ def to_formula private - # A lone Hash is a single entry; nil is nothing; anything else is a list. + # A lone Hash is a single entry; nil is nothing; an Array is the list; + # anything else (incl. a bare reference or a pathological BasicObject) + # becomes a one-element list so coerce_entry validates/rejects it. def normalize(entries) - return [] if entries.nil? + return [] if Compose.type?(NilClass, entries) + return [entries] if Compose.type?(Hash, entries) - entries.is_a?(Hash) ? [entries] : Array(entries) + Compose.type?(Array, entries) ? entries : [entries] end # Exactly one of units:/dimensions: may be non-empty. Store the resolved diff --git a/lib/unitsml/dimension.rb b/lib/unitsml/dimension.rb index 7a71c15..5bc8c18 100644 --- a/lib/unitsml/dimension.rb +++ b/lib/unitsml/dimension.rb @@ -9,7 +9,7 @@ class Dimension attr_accessor :dimension_name def initialize(dimension_name, power_numerator = nil) - @dimension_name = dimension_name + @dimension_name = safe_dimension_name(dimension_name) self.power_numerator = power_numerator end @@ -89,6 +89,18 @@ def xml_postprocess_name private + # Resolve a dimension reference to a known parsable id, as Unit#resolve_ref + # does for units: a pathological name (BasicObject / bad #to_s) or an + # unresolvable one fails fast as UnknownDimensionError instead of crashing + # at render on a nil dim_instance. The parser only ever supplies ids the + # grammar produced, so valid names pass through unchanged. + def safe_dimension_name(name) + string = Compose.safe_string(name) + return string if string && Unitsdb.dimensions.parsables.key?(string) + + raise Errors::UnknownDimensionError.new(value: name) + end + def display_exp return unless power_numerator diff --git a/lib/unitsml/errors.rb b/lib/unitsml/errors.rb index 1c7f254..c15aef6 100644 --- a/lib/unitsml/errors.rb +++ b/lib/unitsml/errors.rb @@ -7,6 +7,8 @@ module Errors autoload :InvalidModelError, "unitsml/errors/invalid_model_error" autoload :InvalidPowerError, "unitsml/errors/invalid_power_error" autoload :InvalidUnitEntryError, "unitsml/errors/invalid_unit_entry_error" + autoload :MisplacedExtenderError, + "unitsml/errors/misplaced_extender_error" autoload :MixedTermsError, "unitsml/errors/mixed_terms_error" autoload :OpalPayloadNotBundledError, "unitsml/errors/opal_payload_not_bundled_error" diff --git a/lib/unitsml/errors/base_error.rb b/lib/unitsml/errors/base_error.rb index c016f08..90e9b6d 100644 --- a/lib/unitsml/errors/base_error.rb +++ b/lib/unitsml/errors/base_error.rb @@ -3,6 +3,17 @@ module Unitsml module Errors class BaseError < StandardError + private + + # Render a user-supplied value for an error message without letting a + # pathological #inspect (one that raises, or a BasicObject that has none) + # escape as a non-Errors::* exception while building the message. + def describe(value) + rendered = value.inspect + rendered.is_a?(String) ? rendered : "(unprintable value)" + rescue StandardError + "(unprintable value)" + end end end end diff --git a/lib/unitsml/errors/invalid_power_error.rb b/lib/unitsml/errors/invalid_power_error.rb index d8868e3..58fe1b1 100644 --- a/lib/unitsml/errors/invalid_power_error.rb +++ b/lib/unitsml/errors/invalid_power_error.rb @@ -19,16 +19,16 @@ def initialize(value:, reason: :unsupported_type) def message_for(reason, value) case reason when :non_integer_float - "[unitsml] Non-integer Float power: #{value.inspect} — use a " \ + "[unitsml] Non-integer Float power: #{describe(value)} — use a " \ "Rational (e.g. Rational(1, 2)) for a fractional exponent." when :invalid_number - "[unitsml] Invalid Number power: #{value.inspect} — a Number " \ + "[unitsml] Invalid Number power: #{describe(value)} — a Number " \ "exponent must hold an integer or n/m fraction (e.g. \"2\", \"1/2\")." when :unsupported_storage - "[unitsml] Cannot store #{value.inspect} as an exponent — " \ + "[unitsml] Cannot store #{describe(value)} as an exponent — " \ "expected a Numeric, Unitsml::Number, or Unitsml::Fenced." else - "[unitsml] Unsupported power: #{value.inspect} — expected an " \ + "[unitsml] Unsupported power: #{describe(value)} — expected an " \ "Integer, Rational, Float or Unitsml::Number." end end diff --git a/lib/unitsml/errors/invalid_unit_entry_error.rb b/lib/unitsml/errors/invalid_unit_entry_error.rb index 2251e5e..1653dcc 100644 --- a/lib/unitsml/errors/invalid_unit_entry_error.rb +++ b/lib/unitsml/errors/invalid_unit_entry_error.rb @@ -20,18 +20,22 @@ def initialize(value:, field: :entry) def message_for(field, value) case field when :operand - "[unitsml] Cannot compose with #{value.inspect} — expected a " \ + "[unitsml] Cannot compose with #{describe(value)} — expected a " \ "Unit, Dimension or Formula." when :prefix - "[unitsml] A dimension entry cannot take a prefix: #{value.inspect}." + "[unitsml] A dimension cannot take a prefix: #{describe(value)}." when :multiplier - "[unitsml] Invalid multiplier: #{value.inspect} — expected a " \ + "[unitsml] Invalid multiplier: #{describe(value)} — expected a " \ "String, :space, or :nospace." when :name - "[unitsml] Invalid name: #{value.inspect} — expected a String " \ + "[unitsml] Invalid name: #{describe(value)} — expected a String " \ "or Symbol." + when :extender + "[unitsml] Invalid extender: #{describe(value)} — expected \"*\", " \ + "\"/\" or \"//\"; custom separators are a render option " \ + "(multiplier:)." else - "[unitsml] Invalid #{field} entry: #{value.inspect} — expected a " \ + "[unitsml] Invalid #{field} entry: #{describe(value)} — expected a " \ "reference (String/Symbol), a Hash, or a matching Unit/Dimension." end end diff --git a/lib/unitsml/errors/misplaced_extender_error.rb b/lib/unitsml/errors/misplaced_extender_error.rb new file mode 100644 index 0000000..556ca63 --- /dev/null +++ b/lib/unitsml/errors/misplaced_extender_error.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # Raised at render time when a composed Formula puts a separator (extender) + # where no operand follows it: the expression ends with an extender ("W/"), + # or two extenders sit together ("W/*"). Such a term list has no valid + # rendering; only a raw extender chain (#extender/#ext or an Extender + # operand) can build one — the parser grammar never produces it. + class MisplacedExtenderError < Unitsml::Errors::BaseError + def initialize(text = nil) + super(text || "[unitsml] Composed expression has a separator " \ + "(extender) with no operand after it; it cannot be " \ + "rendered.") + end + end + end +end diff --git a/lib/unitsml/errors/unknown_dimension_error.rb b/lib/unitsml/errors/unknown_dimension_error.rb index 3d12fb8..c964007 100644 --- a/lib/unitsml/errors/unknown_dimension_error.rb +++ b/lib/unitsml/errors/unknown_dimension_error.rb @@ -9,7 +9,7 @@ class UnknownDimensionError < Unitsml::Errors::BaseError def initialize(value:) @value = value - super("[unitsml] Unknown dimension reference: #{value.inspect} — " \ + super("[unitsml] Unknown dimension reference: #{describe(value)} — " \ "expected a dimension id (e.g. \"dim_L\").") end end diff --git a/lib/unitsml/errors/unknown_prefix_error.rb b/lib/unitsml/errors/unknown_prefix_error.rb index b82371b..08f056e 100644 --- a/lib/unitsml/errors/unknown_prefix_error.rb +++ b/lib/unitsml/errors/unknown_prefix_error.rb @@ -8,7 +8,7 @@ class UnknownPrefixError < Unitsml::Errors::BaseError def initialize(value:, field: :prefix) @value = value @field = field - super("[unitsml] Unknown prefix reference: #{value.inspect}.") + super("[unitsml] Unknown prefix reference: #{describe(value)}.") end end end diff --git a/lib/unitsml/errors/unknown_unit_error.rb b/lib/unitsml/errors/unknown_unit_error.rb index d78c7f5..ade3580 100644 --- a/lib/unitsml/errors/unknown_unit_error.rb +++ b/lib/unitsml/errors/unknown_unit_error.rb @@ -8,8 +8,8 @@ class UnknownUnitError < Unitsml::Errors::BaseError def initialize(value:, field: :unit) @value = value @field = field - super("[unitsml] Unknown unit reference: #{value.inspect} — expected " \ - "a unit symbol id (e.g. \"W\").") + super("[unitsml] Unknown unit reference: #{describe(value)} — " \ + "expected a unit symbol id (e.g. \"W\").") end end end diff --git a/lib/unitsml/formula.rb b/lib/unitsml/formula.rb index 1a64da7..fb30de8 100644 --- a/lib/unitsml/formula.rb +++ b/lib/unitsml/formula.rb @@ -30,6 +30,7 @@ def ==(other) end def to_mathml(options = {}) + guard_renderable! if root options = update_options(options) math = mml_v4_new(:math, display: "block") @@ -48,22 +49,27 @@ def to_mathml(options = {}) end def to_latex(options = {}) + guard_renderable! value.map { |obj| obj.to_latex(update_options(options)) }.join end def to_asciimath(options = {}) + guard_renderable! value.map { |obj| obj.to_asciimath(update_options(options)) }.join end def to_html(options = {}) + guard_renderable! value.map { |obj| obj.to_html(update_options(options)) }.join end def to_unicode(options = {}) + guard_renderable! value.map { |obj| obj.to_unicode(update_options(options)) }.join end def to_xml(options = {}) + guard_renderable! options = update_options(options) if (dimensions_array = extract_dimensions(value)).any? dimensions(sort_dims(dimensions_array), options) @@ -75,6 +81,7 @@ def to_xml(options = {}) end def to_plurimath(options = {}) + guard_renderable! ensure_plurimath_defined! options = update_options(options) if @orig_text.match?(/-$/) @@ -98,6 +105,46 @@ def composable_terms private + # A composed term list must not dangle on a separator or place two + # separators together ("W/", "W/*"); such a term list has no valid + # rendering. The parser never builds these — only a raw extender chain + # (#extender/#ext or an Extender operand) can — so this only ever fires on + # compose misuse. Walks nested Formula/Fenced/Sqrt so a separator misplaced + # inside a group is caught too, regardless of the render path taken. + def guard_renderable!(node = value) + if node.is_a?(Array) + reject_misplaced_extenders!(node) + node.each { |term| guard_renderable!(term) } + elsif renderable_container?(node) + guard_container!(node.value) + end + end + + # A group (Fenced/Sqrt) wrapping a bare separator — Fenced.new("(", ext) — + # is as unrenderable as a dangling one; its value is never a lone Extender + # in valid parser/compose output. + def guard_container!(inner) + raise Errors::MisplacedExtenderError if inner.is_a?(Extender) + + guard_renderable!(inner) + end + + def reject_misplaced_extenders!(terms) + return unless terms.last.is_a?(Extender) || adjacent_extenders?(terms) + + raise Errors::MisplacedExtenderError + end + + def adjacent_extenders?(terms) + terms.each_cons(2).any? do |left, right| + left.is_a?(Extender) && right.is_a?(Extender) + end + end + + def renderable_container?(node) + node.is_a?(Formula) || node.is_a?(Fenced) || node.is_a?(Sqrt) + end + def extract_dimensions(formula) formula.each_with_object([]) do |term, dimensions| case term @@ -220,6 +267,10 @@ def process_value(math, mathml_instances) end def update_options(options) + # Validate render options on every path (a directly-rendered non-root + # Formula would otherwise skip this and leak at the extender). + Compose.validate_name!(options[:name]) + Compose.validate_multiplier!(options[:multiplier]) return options unless root multiplier = options[:multiplier] || explicit_value&.dig(:multiplier) diff --git a/lib/unitsml/opal.rb b/lib/unitsml/opal.rb index 3a4bb6c..893731e 100644 --- a/lib/unitsml/opal.rb +++ b/lib/unitsml/opal.rb @@ -37,6 +37,7 @@ require "unitsml/errors/invalid_model_error" require "unitsml/errors/invalid_power_error" require "unitsml/errors/invalid_unit_entry_error" +require "unitsml/errors/misplaced_extender_error" require "unitsml/errors/mixed_terms_error" require "unitsml/errors/opal_payload_not_bundled_error" require "unitsml/errors/plurimath_load_error" diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb index 325a4ee..311523b 100644 --- a/lib/unitsml/power_numerator.rb +++ b/lib/unitsml/power_numerator.rb @@ -18,8 +18,9 @@ def power_numerator=(power) private def validate_power_type(power) - return power if power.nil? || power.is_a?(Numeric) - return power if power.is_a?(Number) || power.is_a?(Fenced) + if Compose.type_any?([NilClass, Numeric, Number, Fenced], power) + return power + end raise Errors::InvalidPowerError.new(value: power, reason: :unsupported_storage) diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index df20507..66e36f6 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -130,11 +130,12 @@ def inverse_power_numerator # them); nil is not a sentinel and fails fast rather than silently building # a broken unit. Raises for anything unresolvable. def resolve_ref(ref) - raise Errors::UnknownUnitError.new(value: ref) if ref.nil? + raise Errors::UnknownUnitError.new(value: ref) if Compose.type?(NilClass, ref) - ref = ref.to_s - return ref if ref.empty? || ref == Utility::UNKNOWN - return ref if Unitsdb.units.find_by_symbol_id(ref) + string = Compose.safe_string(ref) + raise Errors::UnknownUnitError.new(value: ref) if string.nil? + return string if string.empty? || string == Utility::UNKNOWN + return string if Unitsdb.units.find_by_symbol_id(string) raise Errors::UnknownUnitError.new(value: ref) end @@ -143,16 +144,18 @@ def resolve_ref(ref) # parser keeps its lazy resolution. A string/symbol prefix (the builder) is # validated eagerly and wrapped. def coerce_prefix(prefix) - return prefix if prefix.nil? || prefix.is_a?(Prefix) + return prefix if Compose.type_any?([NilClass, Prefix], prefix) + + name = Compose.safe_string(prefix) + raise Errors::UnknownPrefixError.new(value: prefix) if name.nil? - name = prefix.to_s # A blank prefix means "no prefix", and the UNKNOWN sentinel from internal # decomposition (combine_prefixes for da-/h-prefixed derived units, whose # unit is dropped before rendering) both resolve to nil rather than an # unvalidated bare string that would crash at render time. return if name.strip.empty? || name == Utility::UNKNOWN unless Unitsdb.prefixes.find_by_symbol_name(name) - raise Errors::UnknownPrefixError.new(value: name) + raise Errors::UnknownPrefixError.new(value: prefix) end Prefix.new(name) diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index 0326b50..4257f28 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -55,9 +55,15 @@ def unit_instance(unit) end def quantity_instance(id) - return if id.nil? - - Unitsdb.quantities.find_by_id(id) || Unitsdb.quantities.find_by_name(id) + # A quantity is resolved and silently dropped when unresolvable, so a + # pathological id (BasicObject, or a #to_s that raises/returns nil or a + # non-String) normalizes to nil here rather than leaking a raw + # exception through the render path. + string = Compose.safe_string(id) + return if string.nil? || string.strip.empty? + + Unitsdb.quantities.find_by_id(string) || + Unitsdb.quantities.find_by_name(string) end def units2dimensions(units) diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 9c22de4..069a263 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -10,23 +10,39 @@ def unit(name, power = nil, prefix: nil) Unitsml::Unit.new(name, power, prefix: prefix) end + # to_plurimath is intentionally excluded: exercising it loads the plurimath + # gem process-wide, which pollutes the "plurimath not installed" spec. + def all_formats + %i[to_latex to_asciimath to_unicode to_html to_xml to_mathml] + end + describe "operator DSL" do - it "composes W/m/sr identically to the parsed expression" do - formula = unit("W") / unit("m") / unit("sr") - parsed = Unitsml.parse("W*m^-1*sr^-1") - formats = %i[to_latex to_asciimath to_unicode to_html to_xml to_mathml] - formats.each do |fmt| + it "keeps the / glyph and matches the parser for a single division" do + formula = unit("W") / unit("m") + parsed = Unitsml.parse("W/m") + all_formats.each do |fmt| expect(formula.public_send(fmt)).to eq(parsed.public_send(fmt)) end end + it "chains division with / glyphs, like repeated explicit extenders" do + formula = unit("W") / unit("m") / unit("sr") + explicit = unit("W").ext("/").unit("m", -1).ext("/").unit("sr", -1) + all_formats.each do |fmt| + expect(formula.public_send(fmt)).to eq(explicit.public_send(fmt)) + end + end + it "returns a Formula, not a Unit" do expect(unit("W") / unit("m")).to be_a(Unitsml::Formula) end it "takes powers from the constructor (m/s^2)" do formula = unit("m") / unit("s", 2) - expect(formula.to_latex).to eq(Unitsml.parse("m*s^-2").to_latex) + parsed = Unitsml.parse("m/s^2") + all_formats.each do |fmt| + expect(formula.public_send(fmt)).to eq(parsed.public_send(fmt)) + end end it "does not mutate its operands" do @@ -421,6 +437,263 @@ def latex_for(power) end end + describe "explicit extenders (#extender / #ext)" do + def expect_parity(built, string) + parsed = Unitsml.parse(string) + fmts = %i[to_latex to_asciimath to_unicode to_html to_xml to_mathml] + fmts.each do |fmt| + expect(built.public_send(fmt)).to eq(parsed.public_send(fmt)), fmt.to_s + end + end + + it "reproduces a parsed division byte-for-byte in every format" do + built = Unitsml::Unit.new("W").extender("/").unit("m", -1) + expect_parity(built, "W/m") + end + + it "reproduces mixed separators (W*m/W) in every format" do + built = Unitsml::Unit.new("W").ext("*").unit("m").ext("/").unit("W", -1) + expect_parity(built, "W*m/W") + end + + it "supports the double-slash extender" do + built = Unitsml::Unit.new("m").ext("//").unit("s", -1) + expect_parity(built, "m//s") + end + + it "matches the parser's chained-division output" do + built = Unitsml::Unit.new("W").ext("/").unit("m", -1).ext("/").unit("s") + expect_parity(built, "W/m/s") + end + + it "keeps dimensions glyph-only, like the parser" do + built = Unitsml::Dimension.new("dim_M").ext("/").dimension("dim_L") + expect_parity(built, "dim_M/dim_L") + end + + it "lets the / operator negate after an explicit extender" do + built = Unitsml::Unit.new("W").ext("/") / Unitsml::Unit.new("m") + expect_parity(built, "W/m") + end + + it "does not insert an implicit * after an explicit extender" do + ascii = Unitsml::Unit.new("W").ext("/").unit("m", -1).to_asciimath + expect(ascii).to eq("W/m^-1") + end + + it "rejects any glyph outside the parser grammar" do + w = Unitsml::Unit.new("W") + ["x", "·", nil, ""].each do |bad| + expect { w.ext(bad) } + .to raise_error(Unitsml::Errors::InvalidUnitEntryError, + /render option/) + end + expect(w.ext(:*).unit("m").to_asciimath).to eq("W*m") + end + + it "does not mutate an intermediate chain value" do + partial = Unitsml::Unit.new("W") / Unitsml::Unit.new("m") + before = partial.to_asciimath + partial.unit("sr") + expect(partial.to_asciimath).to eq(before) + end + + it "leaves a dangling extender buildable but not renderable" do + partial = Unitsml::Unit.new("W").ext("/") + expect(partial.value.last).to be_a(Unitsml::Extender) + # a dangling separator has no valid rendering... + expect { partial.to_asciimath } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + # ...but the chain can still be completed + expect(partial.unit("m", -1).to_asciimath).to eq("W/m^-1") + end + + it "rejects a rendered expression that ends in an extender" do + %i[to_latex to_asciimath to_unicode to_html to_xml to_mathml + to_plurimath].each do |fmt| + expect { Unitsml::Unit.new("W").ext("/").public_send(fmt) } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + end + end + + it "rejects two adjacent extenders" do + expect { Unitsml::Unit.new("W").ext("/").ext("*").to_asciimath } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + expect { (Unitsml::Unit.new("W") / Unitsml::Unit.new("m").ext("/")).to_xml } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + end + + it "guards a misplaced extender nested inside a group" do + inner = Unitsml::Formula.new([unit("m"), Unitsml::Extender.new("/")]) + nested = Unitsml::Formula.new([unit("W"), Unitsml::Extender.new("*"), + inner], root: true) + # to_xml (dimension/unit extraction) and the non-root to_mathml branch + # both recurse structurally — the guard must reach the nested group. + expect { nested.to_xml } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + expect { nested.to_mathml } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + end + + it "guards a bare separator wrapped in a group" do + fenced = Unitsml::Fenced.new("(", Unitsml::Extender.new("/"), ")") + sqrt = Unitsml::Sqrt.new(Unitsml::Extender.new("*")) + [fenced, sqrt].each do |inner| + f = Unitsml::Formula.new([unit("W"), inner], root: true) + expect { f.to_asciimath } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + end + end + + it "still guards a units/dimensions mix through an extender" do + expect { Unitsml::Unit.new("W").ext("/").dimension("dim_L") } + .to raise_error(Unitsml::Errors::MixedTermsError) + end + + it "accepts an Extender object as the glyph" do + built = Unitsml::Unit.new("W").ext(Unitsml::Extender.new("/")) + expect_parity(built.unit("m", -1), "W/m") + end + + it "accepts an Extender object as an operator operand" do + slash = Unitsml::Extender.new("/") + built = Unitsml::Unit.new("W") * slash * Unitsml::Unit.new("m", -1) + expect_parity(built, "W/m") + end + + it "rejects an Extender object carrying an out-of-grammar glyph" do + expect { Unitsml::Unit.new("W").ext(Unitsml::Extender.new("x")) } + .to raise_error(Unitsml::Errors::InvalidUnitEntryError) + end + end + + describe "compose input hardening" do + # Every failure through the compose surface must be an Errors::BaseError, + # even for pathological inputs whose #to_s / #inspect raises or is absent. + def hostile + Class.new do + def to_s = raise("boom") + def inspect = raise("boom") + end.new + end + + def non_string_to_s + Class.new { def to_s = 5 }.new + end + + def hostile_inspect + Class.new do + def to_s = "ok" + def inspect = BasicObject.new + end.new + end + + it "raises BaseError (never a raw exception) for a hostile extender" do + expect { Unitsml::Unit.new("W").ext(hostile) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml::Unit.new("W").ext(BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for a hostile unit/dimension reference" do + expect { Unitsml::Unit.new("W").unit(hostile) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml::Dimension.new("dim_M").dimension(hostile) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for a BasicObject operand of * or /" do + expect { Unitsml::Unit.new("W") * BasicObject.new } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml::Unit.new("W") / BasicObject.new } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for a reference whose #to_s returns a non-String" do + bad = non_string_to_s + expect { Unitsml::Unit.new("W").unit(bad) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml.compose(units: [{ unit: bad }]) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for a BasicObject prefix or power" do + expect { Unitsml::Unit.new("W").unit("s", prefix: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml::Unit.new("W").unit("s", BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml::Unit.new("m", BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for BasicObject render metadata" do + formula = Unitsml::Unit.new("W") / Unitsml::Unit.new("m") + expect { formula.name(BasicObject.new).to_xml } + .to raise_error(Unitsml::Errors::BaseError) + expect { formula.multiplier(BasicObject.new).to_xml } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for a BasicObject through the raw constructors" do + expect { Unitsml::Unit.new(BasicObject.new).to_xml } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml::Dimension.new(BasicObject.new).to_xml } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError for a non-enumerable units:/dimensions: value" do + expect { Unitsml.compose(units: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + expect { Unitsml.compose(dimensions: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "silently drops a pathological quantity instead of leaking" do + [BasicObject.new, non_string_to_s, hostile].each do |bad| + expect { Unitsml.compose(units: ["W"], quantity: bad).to_xml } + .not_to raise_error + end + end + + it "still resolves a valid quantity (String or Symbol)" do + expect(Unitsml.compose(units: ["W"], quantity: "radiance").to_xml) + .to include("Quantity") + expect(Unitsml.compose(units: ["W"], quantity: :radiance).to_xml) + .to include("Quantity") + end + + it "raises BaseError for a hostile name:/multiplier: render option" do + formula = Unitsml.compose(units: ["W", "m"]) + expect { formula.to_xml(name: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + expect { formula.to_asciimath(multiplier: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + # the same validation guards a parsed formula's render options + expect { Unitsml.parse("W*m").to_xml(multiplier: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "validates render options on a non-root formula too" do + nested = Unitsml::Formula.new( + [unit("W"), Unitsml::Extender.new("*"), unit("m")], root: false + ) + expect { nested.to_asciimath(multiplier: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises BaseError when an operand's #inspect returns a non-String" do + expect { Unitsml::Unit.new("W") * hostile_inspect } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "raises UnknownDimensionError for an unknown or nil Dimension name" do + expect { Unitsml::Dimension.new("dim_NOPE") } + .to raise_error(Unitsml::Errors::UnknownDimensionError) + expect { Unitsml::Dimension.new(nil) } + .to raise_error(Unitsml::Errors::UnknownDimensionError) + end + end + describe "regressions" do it "does not break parsing of da-/h-prefixed derived units" do expect { Unitsml.parse("hPa").to_xml }.not_to raise_error @@ -428,8 +701,9 @@ def latex_for(power) it "renders division by an inverse term without a spurious ^1" do formula = Unitsml::Unit.new("W") / Unitsml::Unit.new("A", -1) - expect(formula.to_xml).to eq(Unitsml.parse("W*A").to_xml) - expect(formula.to_asciimath).to eq(Unitsml.parse("W*A").to_asciimath) + # dividing by A^-1 leaves A with no exponent (never "^1"), joined by "/" + expect(formula.value.last.power_numerator).to be_nil + expect(formula.to_asciimath).to eq("W/A") end it "fails fast on a blank or missing unit reference" do From 309352b649b909efdfd50388b5d4daa41a4ca15e Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 8 Jul 2026 19:13:07 +0500 Subject: [PATCH 14/18] fix(compose): store power_numerator as a Number; address Copilot review --- docs/README.adoc | 10 +++--- lib/unitsml/compose.rb | 25 +++++++++++++ lib/unitsml/errors/unknown_dimension_error.rb | 5 +-- lib/unitsml/power_numerator.rb | 24 +++++++------ lib/unitsml/utility.rb | 11 +++++- spec/unitsml/composite_spec.rb | 36 +++++++++++++++---- 6 files changed, 86 insertions(+), 25 deletions(-) diff --git a/docs/README.adoc b/docs/README.adoc index a9a6c2a..77f2428 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -254,10 +254,12 @@ formula = Unitsml.compose(units: ["W", { unit: "m", power: -1 }]) formula.to_xml(quantity: "radiance") # emits a element ---- -Composed terms are always joined with `*` (division becomes a negative -exponent), so a composed `Formula` renders like `W*m^-1`, not `W/m`. The -separator is a display choice — pass the `multiplier:` argument to any `to_*` -method (`:space`, `:nospace`, or a custom string such as `"·"`) to change it: +The fluent `#unit`/`#dimension` chain and the keyword form join terms with `*`, +turning division into a negative exponent, so they render like `W*m^-1`. (The +`/` operator and explicit `#extender`/`#ext` separators keep their glyph +instead — see above.) Where terms are `*`-joined, the separator is a display +choice — pass the `multiplier:` argument to any `to_*` method (`:space`, +`:nospace`, or a custom string such as `"·"`) to change it: [source,ruby] ---- diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb index 5178c67..4d884e7 100644 --- a/lib/unitsml/compose.rb +++ b/lib/unitsml/compose.rb @@ -97,6 +97,31 @@ def safe_string(value) nil end + # A raw Numeric exponent, rendered in the parser's string form (a whole + # integer, or an n/m fraction from a Rational). A decimal (non-integer + # Float) has no parser representation and is rejected — nothing beyond what + # the parser accepts is introduced. + def numeric_exponent_string(numeric) + case numeric + when Rational then rational_exponent_string(numeric) + when Float then integer_float_string(numeric) + else numeric.to_s # Integer (and any other whole Numeric) + end + end + + def rational_exponent_string(rational) + rational.denominator == 1 ? rational.numerator.to_s : rational.to_s + end + + def integer_float_string(float) + unless float.finite? && float == float.to_i + raise Errors::InvalidPowerError.new(value: float, + reason: :non_integer_float) + end + + float.to_i.to_s + end + # Class-membership test via Module#=== rather than #is_a?, so a compose # input that is a BasicObject (which has no #is_a?/#nil?) is rejected as a # typed Errors::* instead of the check itself raising a raw NoMethodError. diff --git a/lib/unitsml/errors/unknown_dimension_error.rb b/lib/unitsml/errors/unknown_dimension_error.rb index c964007..9f3b40d 100644 --- a/lib/unitsml/errors/unknown_dimension_error.rb +++ b/lib/unitsml/errors/unknown_dimension_error.rb @@ -2,8 +2,9 @@ module Unitsml module Errors - # Raised when a dimension reference cannot be resolved. Dimension.new has no - # existence check, so compose validates eagerly to stay fail-fast. + # Raised when a dimension reference cannot be resolved — by Dimension.new + # (which validates its name against the parsable ids) or by the compose + # validators, which fail fast with the same error before construction. class UnknownDimensionError < Unitsml::Errors::BaseError attr_reader :value diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb index 311523b..69d62de 100644 --- a/lib/unitsml/power_numerator.rb +++ b/lib/unitsml/power_numerator.rb @@ -1,25 +1,27 @@ # frozen_string_literal: true module Unitsml - # Validated storage for the power_numerator exponent shared by Unit and - # Dimension: only types the render/decomposition paths can handle may be - # stored — nil, a raw Numeric (internal decomposition), a Number, or a - # Fenced exponent (the parser's m^((1/2)) form). Anything else raises - # rather than being stored and crashing later at render. Values are stored - # as given (never coerced): value-level rules such as "the parser cannot - # express a decimal exponent" belong to the compose input boundary. + # Storage for the power_numerator exponent shared by Unit and Dimension. The + # stored value is always a Unitsml::Number (or a Fenced exponent for the + # parser's m^((1/2)) form, or nil for none) — never a bare Numeric: a Numeric + # (e.g. a Float from unit decomposition, or Unit.new("m", 2) from the public + # API) is coerced into a Number so every render/compare/decomposition path can + # rely on the Number interface (raw_value/to_latex/to_i/to_f). Anything else + # (a String, a Hash, a BasicObject) raises instead of being stored and + # crashing later at render. module PowerNumerator attr_reader :power_numerator def power_numerator=(power) - @power_numerator = validate_power_type(power) + @power_numerator = coerce_power_type(power) end private - def validate_power_type(power) - if Compose.type_any?([NilClass, Numeric, Number, Fenced], power) - return power + def coerce_power_type(power) + return power if Compose.type_any?([NilClass, Number, Fenced], power) + if Compose.type?(Numeric, power) + return Number.new(Compose.numeric_exponent_string(power)) end raise Errors::InvalidPowerError.new(value: power, diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index 4257f28..b921ce5 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -124,11 +124,20 @@ def decompose_unit(u) unit_name = Unitsdb.units.find_by_id(k.unit_reference.id).symbols.first.id exponent = (k.power&.to_i || 1) * (u.power_numerator&.to_f || 1) object << { prefix: prefix, - unit: Unit.new(unit_name, exponent, prefix: prefix) } + unit: dimension_base_unit(unit_name, exponent, prefix) } end end end + # A throwaway unit used only to compute the dimension vector. Its exponent + # is kept as the raw Numeric (bypassing Number coercion) so the vector + # still stringifies to a Unitsdb-matchable value; it is never rendered. + def dimension_base_unit(unit_name, exponent, prefix) + unit = Unit.new(unit_name, prefix: prefix) + unit.instance_variable_set(:@power_numerator, exponent) + unit + end + def gather_units(units) units.sort_by { |a| a[:unit]&.unit_name }.each_with_object([]) do |k, m| if m.empty? || m[-1][:unit]&.unit_name != k[:unit]&.unit_name diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 069a263..999b3b2 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -48,7 +48,7 @@ def all_formats it "does not mutate its operands" do squared = unit("s", 2) unit("m") / squared - expect(squared.power_numerator).to eq(2) + expect(squared.power_numerator).to eq(Unitsml::Number.new("2")) end it "raises when units and dimensions are mixed" do @@ -198,14 +198,36 @@ def all_formats .to raise_error(Unitsml::Errors::InvalidPowerError) end - it "stores supported power types as given (no coercion)" do - expect(Unitsml::Unit.new("m", 2).power_numerator).to eq(2) - expect(Unitsml::Unit.new("m", Rational(1, 2)).power_numerator) - .to eq(Rational(1, 2)) - expect(Unitsml::Unit.new("m", 1.5).power_numerator).to eq(1.5) + it "coerces a numeric power into a Unitsml::Number" do + expect(Unitsml::Unit.new("m", 2).power_numerator) + .to eq(Unitsml::Number.new("2")) + expect(Unitsml::Dimension.new("dim_L", 2).power_numerator) + .to eq(Unitsml::Number.new("2")) + end + + it "stores a numeric power in the parser's exponent format" do + # a Rational keeps its fraction form; whole values (incl. Float) reduce + expect(Unitsml::Unit.new("m", Rational(1, 2)).power_numerator.raw_value) + .to eq("1/2") + expect(Unitsml::Unit.new("m", Rational(4, 2)).power_numerator.raw_value) + .to eq("2") + expect(Unitsml::Unit.new("m", 2.0).power_numerator.raw_value).to eq("2") + end + + it "keeps a passed-in Number or Fenced exponent as-is" do number = Unitsml::Number.new("3") expect(Unitsml::Unit.new("m", number).power_numerator).to be(number) - expect(Unitsml::Dimension.new("dim_L", 2).power_numerator).to eq(2) + fenced = Unitsml::Fenced.new("(", Unitsml::Number.new("1/2"), ")") + expect(Unitsml::Unit.new("m", fenced).power_numerator).to be(fenced) + expect(Unitsml::Dimension.new("dim_L", fenced).power_numerator) + .to be(fenced) + end + + it "rejects a decimal power (no parser representation)" do + expect { Unitsml::Unit.new("m", 0.5) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + expect { Unitsml::Unit.new("m", 1.5) } + .to raise_error(Unitsml::Errors::InvalidPowerError) end it "keeps the parser's Fenced exponent storable" do From d1251b99b0128aa39d67ef99c9c0aa850b0641b5 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 8 Jul 2026 20:07:06 +0500 Subject: [PATCH 15/18] docs(compose): note Unit.new/Dimension.new validate on construction --- docs/README.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/README.adoc b/docs/README.adoc index 77f2428..41a3a73 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -274,9 +274,9 @@ and metadata value and raise a `Unitsml::Errors::*` (all subclasses of `Unitsml::Errors::BaseError`) for anything they cannot resolve: an unknown unit, dimension or prefix, a units/dimensions mix, an empty composition, a power the parser cannot express (a decimal exponent — use a `Rational`), or a non-string -`name`/`multiplier`. The operator DSL composes the objects you construct, so — -as with rendering a `Unit` or `Dimension` directly — a hand-built object -carrying an unresolvable prefix or dimension name is the caller's responsibility. +`name`/`multiplier`. `Unit.new`/`Dimension.new` validate their own reference, +prefix and power on construction too, so an unresolvable value raises +immediately whichever surface you use. === Format representation From ce2c108b9721f438c3213fd0bf97b0d710f311b4 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 8 Jul 2026 20:57:49 +0500 Subject: [PATCH 16/18] fix(compose): harden power/prefix/quantity/extender edges (Codex + Copilot) --- lib/unitsml/formula.rb | 8 ++++---- lib/unitsml/power_numerator.rb | 5 ++++- lib/unitsml/unit.rb | 18 ++++++++++++++++- lib/unitsml/unitsdb/quantities.rb | 5 +++-- lib/unitsml/utility.rb | 17 +++++++++++++++- spec/unitsml/composite_spec.rb | 33 ++++++++++++++++++++++++++++--- 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/lib/unitsml/formula.rb b/lib/unitsml/formula.rb index fb30de8..153cb9c 100644 --- a/lib/unitsml/formula.rb +++ b/lib/unitsml/formula.rb @@ -44,7 +44,7 @@ def to_mathml(options = {}) generated_math.force_encoding("UTF-8") else - value.map { |obj| obj.to_mathml(options) } + value.map { |obj| obj.to_mathml(update_options(options)) } end end @@ -130,9 +130,9 @@ def guard_container!(inner) end def reject_misplaced_extenders!(terms) - return unless terms.last.is_a?(Extender) || adjacent_extenders?(terms) - - raise Errors::MisplacedExtenderError + misplaced = terms.first.is_a?(Extender) || + terms.last.is_a?(Extender) || adjacent_extenders?(terms) + raise Errors::MisplacedExtenderError if misplaced end def adjacent_extenders?(terms) diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb index 69d62de..711b668 100644 --- a/lib/unitsml/power_numerator.rb +++ b/lib/unitsml/power_numerator.rb @@ -20,7 +20,10 @@ def power_numerator=(power) def coerce_power_type(power) return power if Compose.type_any?([NilClass, Number, Fenced], power) - if Compose.type?(Numeric, power) + # Only the numeric types the parser can express (Integer, Rational, whole + # Float) coerce; other Numerics (BigDecimal, Complex, ...) are rejected + # rather than stringified into a non-parser exponent. + if Compose.type_any?([Integer, Rational, Float], power) return Number.new(Compose.numeric_exponent_string(power)) end diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index 66e36f6..15a14a6 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -144,8 +144,13 @@ def resolve_ref(ref) # parser keeps its lazy resolution. A string/symbol prefix (the builder) is # validated eagerly and wrapped. def coerce_prefix(prefix) - return prefix if Compose.type_any?([NilClass, Prefix], prefix) + return prefix if Compose.type?(NilClass, prefix) + return validate_prefix_object(prefix) if Compose.type?(Prefix, prefix) + coerce_prefix_string(prefix) + end + + def coerce_prefix_string(prefix) name = Compose.safe_string(prefix) raise Errors::UnknownPrefixError.new(value: prefix) if name.nil? @@ -161,6 +166,17 @@ def coerce_prefix(prefix) Prefix.new(name) end + # A pre-built Prefix (the parse path) is kept as-is so the parser retains + # its lazy resolution; a directly-constructed one carrying an unresolvable + # name is rejected here rather than crashing at render. + def validate_prefix_object(prefix) + name = prefix.prefix_name.to_s + return prefix if name.strip.empty? || name == Utility::UNKNOWN + return prefix if Unitsdb.prefixes.find_by_symbol_name(name) + + raise Errors::UnknownPrefixError.new(value: prefix) + end + def display_exp return unless power_numerator diff --git a/lib/unitsml/unitsdb/quantities.rb b/lib/unitsml/unitsdb/quantities.rb index bb68fbd..f3df6ca 100644 --- a/lib/unitsml/unitsdb/quantities.rb +++ b/lib/unitsml/unitsdb/quantities.rb @@ -13,9 +13,10 @@ def find_by_id(q_id) # case-insensitive. Non-English synonyms are skipped (some contain # commas the parser's comma-metadata handling would truncate). def find_by_name(name) - return if name.to_s.strip.empty? + string = Compose.safe_string(name) + return if string.nil? || string.strip.empty? - key = name.to_s.downcase + key = string.downcase quantities.find { |quantity| name_matches?(quantity, key) } end diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index b921ce5..291e956 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -394,7 +394,10 @@ def quantity(normtext, instance, dims = nil) # than leaking the raw reference as an xml:id. return if instance && record.nil? - id = canonical_nist_id(record) || unit.quantity_references&.first&.id + # unit is nil for a composite; fall back to the record's own identifier + # so a quantity without a NIST id still emits a usable xml:id. + id = canonical_nist_id(record) || quantity_reference_id(unit) || + record_identifier_id(record) model_quantity_xml(id, quantity_dimension_url(unit, dims), record&.quantity_type) end @@ -412,6 +415,18 @@ def canonical_nist_id(record) record&.identifiers&.find { |identifier| identifier.type == "nist" }&.id end + def quantity_reference_id(unit) + return unless unit + + unit.quantity_references&.first&.id + end + + def record_identifier_id(record) + return unless record + + record.identifiers&.first&.id + end + def unit_nist_id(unit) return unless unit return unit.nist_id if unit.is_a?(Unitsml::Unitsdb::Unit) diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index 999b3b2..b3fbb5b 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -340,9 +340,8 @@ def latex_for(power) expect(sym.to_xml).to eq(str.to_xml) end - it "validates the prefix of a pre-built Unit entry" do - bad = Unitsml::Unit.new("m", prefix: Unitsml::Prefix.new("zz")) - expect { Unitsml.compose(units: [bad]) } + it "validates a Prefix object at construction" do + expect { Unitsml::Unit.new("m", prefix: Unitsml::Prefix.new("zz")) } .to raise_error(Unitsml::Errors::UnknownPrefixError) end @@ -714,6 +713,34 @@ def inspect = BasicObject.new expect { Unitsml::Dimension.new(nil) } .to raise_error(Unitsml::Errors::UnknownDimensionError) end + + it "rejects a numeric power the parser can't express" do + require "bigdecimal" + expect { Unitsml::Unit.new("m", BigDecimal("1.5")) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + expect { Unitsml::Unit.new("m", Complex(2, 0)) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + it "raises MisplacedExtenderError for a leading separator" do + formula = Unitsml::Formula.new( + [Unitsml::Extender.new("*"), Unitsml::Unit.new("m")], root: true + ) + expect { formula.to_latex } + .to raise_error(Unitsml::Errors::MisplacedExtenderError) + end + + it "validates a render option on a non-root to_mathml" do + nested = Unitsml::Formula.new([Unitsml::Unit.new("W")], root: false) + expect { nested.to_mathml(multiplier: BasicObject.new) } + .to raise_error(Unitsml::Errors::BaseError) + end + + it "drops a pathological quantity through find_by_name too" do + hostile = Class.new { def to_s = raise("boom") }.new + expect { Unitsml.compose(units: ["W"], quantity: hostile).to_xml } + .not_to raise_error + end end describe "regressions" do From c6970dea00f499ffe0c67ee4cdf96eee0aa7474a Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 8 Jul 2026 21:13:40 +0500 Subject: [PATCH 17/18] fix(compose): harden Prefix-object name via safe_string (Codex re-review) --- lib/unitsml/unit.rb | 3 ++- spec/unitsml/composite_spec.rb | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index 15a14a6..1289ee8 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -170,7 +170,8 @@ def coerce_prefix_string(prefix) # its lazy resolution; a directly-constructed one carrying an unresolvable # name is rejected here rather than crashing at render. def validate_prefix_object(prefix) - name = prefix.prefix_name.to_s + name = Compose.safe_string(prefix.prefix_name) + raise Errors::UnknownPrefixError.new(value: prefix) if name.nil? return prefix if name.strip.empty? || name == Utility::UNKNOWN return prefix if Unitsdb.prefixes.find_by_symbol_name(name) diff --git a/spec/unitsml/composite_spec.rb b/spec/unitsml/composite_spec.rb index b3fbb5b..fd3766d 100644 --- a/spec/unitsml/composite_spec.rb +++ b/spec/unitsml/composite_spec.rb @@ -343,6 +343,8 @@ def latex_for(power) it "validates a Prefix object at construction" do expect { Unitsml::Unit.new("m", prefix: Unitsml::Prefix.new("zz")) } .to raise_error(Unitsml::Errors::UnknownPrefixError) + expect { Unitsml::Unit.new("m", prefix: Unitsml::Prefix.new(BasicObject.new)) } + .to raise_error(Unitsml::Errors::BaseError) end it "validates the power of a pre-built Unit entry" do From cd038bce80b7b13f2c7c3ef1f016a36eed03cbce Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 8 Jul 2026 21:53:46 +0500 Subject: [PATCH 18/18] docs(compose): clarify validation comments --- lib/unitsml/compose.rb | 6 +++--- lib/unitsml/power_numerator.rb | 17 +++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/unitsml/compose.rb b/lib/unitsml/compose.rb index 4d884e7..0ec6a96 100644 --- a/lib/unitsml/compose.rb +++ b/lib/unitsml/compose.rb @@ -59,9 +59,9 @@ def unit_ref(reference) raise Errors::UnknownUnitError.new(value: reference) end - # Validate a dimension reference (Dimension.new does not), so the keyword - # form and the fluent #dimension chain both fail fast with the same - # Errors::UnknownDimensionError instead of crashing at render. + # Normalize and validate a dimension reference at the shared compose + # boundary, so the keyword form, fluent #dimension chain, and Dimension.new + # all fail fast with the same Errors::UnknownDimensionError behavior. def dimension_ref(reference) string = safe_string(reference) return string if string && Unitsdb.dimensions.parsables.key?(string) diff --git a/lib/unitsml/power_numerator.rb b/lib/unitsml/power_numerator.rb index 711b668..26dea69 100644 --- a/lib/unitsml/power_numerator.rb +++ b/lib/unitsml/power_numerator.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true module Unitsml - # Storage for the power_numerator exponent shared by Unit and Dimension. The - # stored value is always a Unitsml::Number (or a Fenced exponent for the - # parser's m^((1/2)) form, or nil for none) — never a bare Numeric: a Numeric - # (e.g. a Float from unit decomposition, or Unit.new("m", 2) from the public - # API) is coerced into a Number so every render/compare/decomposition path can - # rely on the Number interface (raw_value/to_latex/to_i/to_f). Anything else - # (a String, a Hash, a BasicObject) raises instead of being stored and - # crashing later at render. + # Storage for the power_numerator exponent shared by Unit and Dimension. + # Values assigned through #power_numerator= are always a Unitsml::Number (or a + # Fenced exponent for the parser's m^((1/2)) form, or nil for none) -- never a + # bare Numeric: public Numeric inputs like Unit.new("m", 2) are coerced into a + # Number so render/compare paths can rely on raw_value/to_latex/to_i/to_f. + # Internal dimension-vector throwaway units may bypass this setter and keep a + # raw Numeric in @power_numerator; those objects are never rendered. + # Anything else (a String, a Hash, a BasicObject) raises instead of being + # stored and crashing later at render. module PowerNumerator attr_reader :power_numerator