diff --git a/docs/README.adoc b/docs/README.adoc index d69b7e4..41a3a73 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -149,13 +149,135 @@ 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`. 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, rendered "W/m^-1/sr^-1" +Unitsml::Unit.new("W") / Unitsml::Unit.new("m") / Unitsml::Unit.new("sr") + +# 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 +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") +---- + +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 +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 +`{ unit:, power:, prefix: }` hash, or a pre-built `Unitsml::Unit`; a +`dimensions:` entry is a dimension id or a `{ dimension:, power: }` hash: + +[source,ruby] +---- +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 +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 +---- + +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] +---- +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, +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`. `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 diff --git a/lib/unitsml.rb b/lib/unitsml.rb index b71d12d..f510d7f 100644 --- a/lib/unitsml.rb +++ b/lib/unitsml.rb @@ -6,6 +6,7 @@ module Unitsml module_function + autoload :Compose, "unitsml/compose" autoload :Dimension, "unitsml/dimension" autoload :Configuration, "unitsml/configuration" autoload :Errors, "unitsml/errors" @@ -20,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" @@ -33,4 +35,12 @@ module Unitsml def parse(string) Unitsml::Parser.new(string).parse end + + 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/compose.rb b/lib/unitsml/compose.rb new file mode 100644 index 0000000..0ec6a96 --- /dev/null +++ b/lib/unitsml/compose.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +module Unitsml + # 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" + 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 = safe_string(reference) + return string if string && !string.strip.empty? + + raise Errors::UnknownUnitError.new(value: reference) + end + + # 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) + + 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) + type?(Prefix, 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 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 + + # 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. + 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 new file mode 100644 index 0000000..4c3b7d8 --- /dev/null +++ b/lib/unitsml/compose/builder.rb @@ -0,0 +1,198 @@ +# 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. 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 == "/") + 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, + # 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 + + # 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) + 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) + Compose.validate_name!(name) + Compose.validate_multiplier!(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..102f82f --- /dev/null +++ b/lib/unitsml/compose/composable.rb @@ -0,0 +1,93 @@ +# 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. + # 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) + build_formula(other, "*") + end + + 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(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), + 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 + # 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 + # supply its terms to the other side of `*`/`/`. + def composable_terms + [self] + end + + 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 + + # 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) + Compose.type_any?([Unit, Dimension, Formula], other) + end + end + end +end diff --git a/lib/unitsml/compose/composite.rb b/lib/unitsml/compose/composite.rb new file mode 100644 index 0000000..dddc707 --- /dev/null +++ b/lib/unitsml/compose/composite.rb @@ -0,0 +1,145 @@ +# 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; 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 Compose.type?(NilClass, entries) + return [entries] if Compose.type?(Hash, entries) + + Compose.type?(Array, entries) ? entries : [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 + + # 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) + Compose.unit_ref(reference) + end + + def require_dimension(reference) + Compose.dimension_ref(reference) + end + + def require_power(power) + Compose.power(power) + end + + def prefix_ref(prefix) + Compose.prefix_ref(prefix) + end + + def blank_reference + @kind == :dimension ? require_dimension(nil) : require_reference(nil) + 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/dimension.rb b/lib/unitsml/dimension.rb index 47b508f..5bc8c18 100644 --- a/lib/unitsml/dimension.rb +++ b/lib/unitsml/dimension.rb @@ -3,12 +3,14 @@ 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 + @dimension_name = safe_dimension_name(dimension_name) + self.power_numerator = power_numerator end def ==(other) @@ -79,8 +81,33 @@ 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 + # 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 + + 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 33988a7..c15aef6 100644 --- a/lib/unitsml/errors.rb +++ b/lib/unitsml/errors.rb @@ -3,10 +3,19 @@ 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 :MisplacedExtenderError, + "unitsml/errors/misplaced_extender_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, "unitsml/errors/unsupported_payload_type_error" end 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/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..58fe1b1 --- /dev/null +++ b/lib/unitsml/errors/invalid_power_error.rb @@ -0,0 +1,37 @@ +# 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: #{describe(value)} — use a " \ + "Rational (e.g. Rational(1, 2)) for a fractional exponent." + when :invalid_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 #{describe(value)} as an exponent — " \ + "expected a Numeric, Unitsml::Number, or Unitsml::Fenced." + else + "[unitsml] Unsupported power: #{describe(value)} — 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..1653dcc --- /dev/null +++ b/lib/unitsml/errors/invalid_unit_entry_error.rb @@ -0,0 +1,44 @@ +# 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 #{describe(value)} — expected a " \ + "Unit, Dimension or Formula." + when :prefix + "[unitsml] A dimension cannot take a prefix: #{describe(value)}." + when :multiplier + "[unitsml] Invalid multiplier: #{describe(value)} — expected a " \ + "String, :space, or :nospace." + when :name + "[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: #{describe(value)} — expected a " \ + "reference (String/Symbol), a Hash, or a matching Unit/Dimension." + end + end + end + 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/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_dimension_error.rb b/lib/unitsml/errors/unknown_dimension_error.rb new file mode 100644 index 0000000..9f3b40d --- /dev/null +++ b/lib/unitsml/errors/unknown_dimension_error.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + # 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 + + def initialize(value:) + @value = value + super("[unitsml] Unknown dimension reference: #{describe(value)} — " \ + "expected a dimension id (e.g. \"dim_L\").") + 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..08f056e --- /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: #{describe(value)}.") + 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..ade3580 --- /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: #{describe(value)} — " \ + "expected a unit symbol id (e.g. \"W\").") + end + end + end +end diff --git a/lib/unitsml/formula.rb b/lib/unitsml/formula.rb index 15bc9e1..153cb9c 100644 --- a/lib/unitsml/formula.rb +++ b/lib/unitsml/formula.rb @@ -6,6 +6,7 @@ module Unitsml class Formula include MathmlHelper + include Compose::Composable attr_accessor :value, :explicit_value, :root @@ -29,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") @@ -42,27 +44,32 @@ 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 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) @@ -74,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?(/-$/) @@ -89,8 +97,54 @@ def dimensions_extraction extract_dimensions(value) end + # 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 + # 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) + misplaced = terms.first.is_a?(Extender) || + terms.last.is_a?(Extender) || adjacent_extenders?(terms) + raise Errors::MisplacedExtenderError if misplaced + 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 @@ -135,10 +189,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 +229,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 @@ -210,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/number.rb b/lib/unitsml/number.rb index 608abed..3b8dede 100644 --- a/lib/unitsml/number.rb +++ b/lib/unitsml/number.rb @@ -9,7 +9,11 @@ class Number alias raw_value value def initialize(value) - @value = value + # 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 eee4d07..893731e 100644 --- a/lib/unitsml/opal.rb +++ b/lib/unitsml/opal.rb @@ -33,9 +33,17 @@ 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/misplaced_extender_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" require "unitsml/opal/database_payload" @@ -82,6 +90,14 @@ require "unitsml/model/units/symbol" require "unitsml/model/units/system" +require "unitsml/compose" +require "unitsml/compose/term_tree" +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..26dea69 --- /dev/null +++ b/lib/unitsml/power_numerator.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Unitsml + # 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 + + def power_numerator=(power) + @power_numerator = coerce_power_type(power) + end + + private + + def coerce_power_type(power) + return power if Compose.type_any?([NilClass, Number, Fenced], 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 + + raise Errors::InvalidPowerError.new(value: power, + reason: :unsupported_storage) + end + end +end diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index fd65b22..1289ee8 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -3,8 +3,10 @@ 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 @@ -12,9 +14,9 @@ class Unit def initialize(unit_name, power_numerator = nil, prefix: nil) - @prefix = prefix - @unit_name = unit_name - @power_numerator = power_numerator + @prefix = coerce_prefix(prefix) + @unit_name = resolve_ref(unit_name) + self.power_numerator = power_numerator end def ==(other) @@ -122,6 +124,60 @@ def inverse_power_numerator private + # 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); 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 Compose.type?(NilClass, 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 + + # 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 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? + + # 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: prefix) + end + + 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 = 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) + + 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 11383fa..f3df6ca 100644 --- a/lib/unitsml/unitsdb/quantities.rb +++ b/lib/unitsml/unitsdb/quantities.rb @@ -8,6 +8,27 @@ 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) + string = Compose.safe_string(name) + return if string.nil? || string.strip.empty? + + key = string.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/utility.rb b/lib/unitsml/utility.rb index 61faf18..291e956 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -55,7 +55,15 @@ def unit_instance(unit) end def quantity_instance(id) - Unitsdb.quantities.find_by_id(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) @@ -116,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 @@ -368,14 +385,46 @@ 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) + # An explicit but unresolvable quantity emits nothing (silent), rather + # than leaking the raw reference as an xml:id. + return if instance && record.nil? + + # 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 + + # 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) + 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) @@ -409,13 +458,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..fd3766d --- /dev/null +++ b/spec/unitsml/composite_spec.rb @@ -0,0 +1,829 @@ +# 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 + + # 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 "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) + 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 + squared = unit("s", 2) + unit("m") / squared + expect(squared.power_numerator).to eq(Unitsml::Number.new("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 "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 "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 both units: and dimensions: are given" do + expect { Unitsml.compose(units: ["W"], dimensions: ["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 + + 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("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 "rejects a short name (symbol ids only, like the parser)" do + expect { Unitsml::Unit.new("watt") } + .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 + 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, + /Cannot store .+ as an exponent/) + 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 "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) + 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 + 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") + 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" 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 { 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 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 + bad = Unitsml::Unit.new("m", Unitsml::Number.new("abc")) + expect { Unitsml.compose(units: [bad]) } + .to raise_error(Unitsml::Errors::InvalidPowerError) + end + + 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 + + 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 "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 "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 "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 + 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") + 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 "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 + + 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 + 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) + # 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 + 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 + + 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(" 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("