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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions docs/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ 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_reference`/`#name`/`#multiplier` attach metadata. Metadata must come
last, since each chained term builds a fresh `Formula`:
last. Each chained term, and each `*` or `/`, builds a fresh `Formula` that does
not carry earlier metadata. The same holds for the comma metadata of a parsed
formula: `Unitsml.parse("W*m^-1, quantity: radiance") * Unitsml::Unit.new("sr")`
has no quantity.

[source,ruby]
----
Expand Down Expand Up @@ -275,14 +278,20 @@ 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.
`Unitsml.compose` and the fluent chain validate every reference, power and
prefix, and the `name` and `multiplier` metadata. They 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. A `Unitsml::Number` power must hold a number (`Number.new("abc")`
raises).

The `quantity` metadata is looked up when `to_xml` runs, by NIST id (such as
`"NISTq1"`) or by English name (such as `"radiance"`). A value that matches no
quantity is not an error: `to_xml` leaves out the `<Quantity>` element.

==== Text of composed units

Expand All @@ -291,8 +300,10 @@ A composed `Formula` keeps its original term tree for rendering, so `to_latex`,

Its `norm_text` is a flat list of the unit leaves joined by `*`, with each
stored power written in parentheses. This text parses back into the same units,
prefixes and powers. A single division of two plain units keeps the familiar
form.
prefixes and power values. A single division of two plain units keeps the
familiar form. A square root is written as its exponent, so a composed
`sqrt(Hz)` becomes `Hz^(5/10)`: the parsed text has the same power, but renders
without the root sign.

[source,ruby]
----
Expand All @@ -310,6 +321,9 @@ The `norm_text` of a parsed `Formula` is unchanged.
is parsed. A `Unit` or `Formula` is used without parsing. The older
`unit_expression:` keyword still accepts a string. Give only one of the two
keywords. Without a unit, or with `unit: nil`, the quantity is a pure number.
`unit:` does not take a bare `Unitsml::Dimension`; build a dimension quantity
from a `Formula`, for example
`unit: Unitsml.compose(dimensions: ["dim_L"])`.

[source,ruby]
----
Expand Down
8 changes: 6 additions & 2 deletions lib/unitsml/compose/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ def attach_metadata(node, **extras)

added = build_metadata(extras[:quantity], extras[:name],
extras[:multiplier])
copy = node.dup
# A deep copy, so the result never shares terms with the caller's
# Formula (a shallow dup would share the term array and leaves).
copy = copy_formula(node)
copy.explicit_value = merge_metadata(node.explicit_value, added)
copy
end
Expand Down Expand Up @@ -211,9 +213,11 @@ 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.
# Negate an exponent string: nil (no exponent) -> "-1", zero stays
# "0" (never "-0"), else flip sign.
def negate_string(raw)
return "-1" if raw.nil?
return raw if raw.match?(/\A-?0+\z/)

raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}"
end
Expand Down
28 changes: 27 additions & 1 deletion lib/unitsml/power_numerator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,17 @@ def power_numerator=(power)

private

# The exponent lexemes the library itself stores: an integer, an n/m or
# n//m fraction (parser), or a decimal ("0.5" for sqrt, "2.0" from unit
# decomposition). Anything else ("abc") has no numeric meaning and would
# render and serialize as garbage.
NUMBER_LEXEME = %r{\A-?\d+(\.\d+)?(//?-?\d+)?\z}
private_constant :NUMBER_LEXEME

def coerce_power_type(power)
return power if Compose.type_any?([NilClass, Number, Fenced], power)
return power if Compose.type?(NilClass, power)
return validate_number_power(power) if Compose.type?(Number, power)
return validate_fenced_power(power) if Compose.type?(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.
Expand All @@ -31,5 +40,22 @@ def coerce_power_type(power)
raise Errors::InvalidPowerError.new(value: power,
reason: :unsupported_storage)
end

def validate_number_power(power)
lexeme = Compose.safe_string(power.raw_value)
return power if lexeme&.match?(NUMBER_LEXEME)

raise Errors::InvalidPowerError.new(value: power,
reason: :invalid_number)
end

# A Fenced exponent is checked at its innermost Number; other parser
# payloads inside the fence are kept as-is.
def validate_fenced_power(power)
inner = power.value
inner = inner.value while Compose.type?(Fenced, inner)
validate_number_power(inner) if Compose.type?(Number, inner)
power
end
end
end
59 changes: 57 additions & 2 deletions spec/unitsml/composite_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,10 @@ def latex_for(power)
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]) }
# A non-numeric Number is now rejected by Unit.new itself; a decimal
# Number is storable but still has no parser form, so compose rejects it.
decimal = Unitsml::Unit.new("m", Unitsml::Number.new("0.5"))
expect { Unitsml.compose(units: [decimal]) }
.to raise_error(Unitsml::Errors::InvalidPowerError)
end

Expand Down Expand Up @@ -1266,4 +1268,57 @@ def mutable_parts(formula)
/Invalid Number power/)
end
end

describe "builder gap fixes" do
let(:original) { Unitsml::Unit.new("W") * Unitsml::Unit.new("m") }
let(:bad_number) { Unitsml::Number.new("abc") }

{
name: "x", quantity_reference: "radiance", multiplier: "x"
}.each do |setter, value|
it "gives ##{setter} its own copy of the terms" do
before = original.to_xml
copy = original.public_send(setter, value)
copy.value.last.unit_name = "s"
expect(original.to_asciimath).to eq("W*m")
expect(original.to_xml).to eq(before)
end
end

it "rejects a non-numeric Number power on construction" do
expect { Unitsml::Unit.new("m", bad_number) }
.to raise_error(Unitsml::Errors::InvalidPowerError,
/Invalid Number power/)
expect { Unitsml::Dimension.new("dim_L", bad_number) }
.to raise_error(Unitsml::Errors::InvalidPowerError)
end

it "rejects a non-numeric power via the setter and inside a fence" do
expect { Unitsml::Unit.new("m").power_numerator = bad_number }
.to raise_error(Unitsml::Errors::InvalidPowerError)
fenced = Unitsml::Fenced.new("(", bad_number, ")")
expect { Unitsml::Unit.new("m", fenced) }
.to raise_error(Unitsml::Errors::InvalidPowerError)
end

%w[2 -1 0 1/2 -1/2 1//2 1/-2 0.5 2.0 -1.5].each do |raw|
it "still stores the library's exponent form #{raw.inspect}" do
expect { Unitsml::Unit.new("m", Unitsml::Number.new(raw)) }
.not_to raise_error
end
end

%w[m^1/2 m^1//2 m^((1/2)) m^((-1)) sqrt(m) sqrt(Hz) m^0 m*m*s
kg*m^2*s^-2].each do |text|
it "still renders the parsed #{text.inspect}" do
expect { Unitsml.parse(text).to_xml }.not_to raise_error
end
end

it "keeps a zero power at zero when dividing" do
formula = Unitsml::Unit.new("s") / Unitsml::Unit.new("m", 0)
expect(formula.to_asciimath).to eq("s/m^0")
expect(formula.norm_text).to eq("s*m^(0)")
end
end
end
Loading