From 43222363579be2d06a27c10b8a386e706d381761 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 9 Jun 2025 22:11:08 +0200 Subject: [PATCH 1/6] feat: Removes CLI Swift 6 mutability warnings --- Package.resolved | 4 ++-- Sources/CLI/Convert.swift | 6 +++--- Sources/CLI/List.swift | 2 +- Sources/CLI/Unit.swift | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Package.resolved b/Package.resolved index f314be3..6f174f6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86", - "version": "1.2.1" + "revision": "011f0c765fb46d9cac61bca19be0527e99c98c8b", + "version": "1.5.1" } } ] diff --git a/Sources/CLI/Convert.swift b/Sources/CLI/Convert.swift index 8a9f25f..ef79b10 100644 --- a/Sources/CLI/Convert.swift +++ b/Sources/CLI/Convert.swift @@ -2,7 +2,7 @@ import ArgumentParser import Units struct Convert: ParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "Convert a measurement expression to a specified unit.", discussion: """ Run `unit list` to see the supported unit symbols and names. Unless arguments are wrapped \ @@ -39,14 +39,14 @@ struct Convert: ParsableCommand { } } -extension Expression: ExpressibleByArgument { +extension Expression: @retroactive ExpressibleByArgument { public convenience init?(argument: String) { let argument = argument.replacingOccurrences(of: "_", with: " ") try? self.init(argument) } } -extension Units.Unit: ExpressibleByArgument { +extension Units.Unit: @retroactive ExpressibleByArgument { public init?(argument: String) { if let unit = try? Self(fromName: argument) { self = unit diff --git a/Sources/CLI/List.swift b/Sources/CLI/List.swift index 9084e81..cb31fc7 100644 --- a/Sources/CLI/List.swift +++ b/Sources/CLI/List.swift @@ -2,7 +2,7 @@ import ArgumentParser import Units struct List: ParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "Print a table of the available units, their symbols, and their dimensionality." ) diff --git a/Sources/CLI/Unit.swift b/Sources/CLI/Unit.swift index 05e7505..97b970a 100644 --- a/Sources/CLI/Unit.swift +++ b/Sources/CLI/Unit.swift @@ -1,7 +1,7 @@ import ArgumentParser struct Unit: ParsableCommand { - static var configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "A utility for performing unit conversions.", subcommands: [Convert.self, List.self] ) From c9a95ff42c6349e84f9b1cef56d5be1eaf834b69 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 9 Jun 2025 22:06:03 +0200 Subject: [PATCH 2/6] feat!: Removes reliance on registry singleton This does so by introducing a RegistryBuilder that custom units must be attached to, and is used for all Unit lookups. This was (rightfully) required by Swift 6 memory safety, since the existing implementation had issues with shared mutable state. I attempted to minimize breaking changes, but some were required. --- README.md | 59 ++-- Sources/CLI/Convert.swift | 6 +- Sources/CLI/List.swift | 2 +- Sources/Units/Expression.swift | 4 +- Sources/Units/Measurement/Measurement.swift | 6 +- Sources/Units/Parser.swift | 6 +- Sources/Units/Registry.swift | 342 +------------------ Sources/Units/RegistryBuilder.swift | 347 ++++++++++++++++++++ Sources/Units/Unit/DefaultUnits.swift | 5 +- Sources/Units/Unit/Unit+DefaultUnits.swift | 6 +- Sources/Units/Unit/Unit.swift | 108 +----- Tests/UnitsTests/MeasurementTests.swift | 58 ++-- Tests/UnitsTests/ParserTests.swift | 56 ++-- Tests/UnitsTests/UnitTests.swift | 2 + 14 files changed, 486 insertions(+), 521 deletions(-) create mode 100644 Sources/Units/RegistryBuilder.swift diff --git a/README.md b/README.md index df28483..42ce9cd 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,18 @@ For a list of the default units and their conversion factors, see the [`DefaultU ## Custom Units -To extend this package, users can define their own custom units using `Unit.define`: +The unit system is backed by a `Registry` that maps unit symbols to their metadata. To add new units, use `RegistryBuilder`, and pass it to any operation that converts from a `String` to a `Unit`: ```swift -let centifoot = try Unit.define( +let registryBuilder = RegistryBuilder() +registryBuilder.addUnit( name: "centifoot", symbol: "cft", dimension: [.Length: 1], coefficient: 0.003048 // This is the conversion to meters ) +let registry = registryBuilder.registry() +let centifoot = try Unit(fromSymbol: "cft", registry: registry) let measurement = Measurement(value: 5, unit: centifoot) print(5.measured(in: .foot).convert(to: centifoot)) @@ -137,24 +140,38 @@ print(5.measured(in: .foot).convert(to: centifoot)) This returns a Unit object that can be used in arithmetic, conversions, and serialization. +### Encoding and Decoding + +When using custom units, you must provide the custom registry to the encoder or decoder using `userInfo`: + +```swift +let decoder = JSONDecoder() +decoder.userInfo[Unit.registryUserInfoKey] = registry // Required to recognize custom units. +try decoder.decode(Unit.self, from: #""cft/s""#.data(using: .utf8)) +``` + ### Non-scientific Units For "non-scientific" units, it is typically appropriate to use the `Amount` quantity. Through this approach, you can easily build up an impromptu conversion system on the fly. For example: ```swift -let apple = try Unit.define( +let registryBuilder = RegistryBuilder() +try registryBuilder.addUnit( name: "apple", symbol: "apple", dimension: [.Amount: 1], coefficient: 1 ) - -let carton = try Unit.define( +try registryBuilder.addUnit( name: "carton", symbol: "carton", dimension: [.Amount: 1], coefficient: 48 ) +let registry = registryBuilder.registry() + +let apple = try Unit(fromSymbol: "apple", registry: registry) +let carton = try Unit(fromSymbol: "carton", registry: registry) let harvest = 288.measured(in: apple) print(harvest.convert(to: carton)) // Prints '6.0 carton' @@ -163,12 +180,13 @@ print(harvest.convert(to: carton)) // Prints '6.0 carton' We can extend this example to determine how many cartons a group of people can pick in a week: ```swift -let person = try Unit.define( +try registryBuilder.addUnit( name: "person", symbol: "person", dimension: [.Amount: 1], coefficient: 1 ) +let person = try Unit(fromSymbol: "person", registry: registryBuilder.registry()) let personPickRate = 600.measured(in: apple / .day / person) let workforce = 4.measured(in: person) @@ -176,35 +194,6 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week) print(weeklyCartons) // Prints '350.0 carton/week' ``` -### Adding custom units to the Registry - -To support deserialization and runtime querying of available units, this package keeps a global registry of the default units. The `Unit.define` method does not insert new definitions into this registry. While this avoids conflicts and prevents race conditions, it also means that units created using `Unit.define` cannot be deserialized correctly or looked up using `Unit(fromSymbol:)` - -If these features are absolutely needed, and the implications are understood, custom units can be added to the registry using `Unit.register`: - -```swift -let centifoot = try Unit.register( - name: "centifoot", - symbol: "cft", - dimension: [.Length: 1], - coefficient: 0.003048 // This is the conversion to meters -) -``` - -Note that you may only register the unit once globally, and afterwards it should be accessed either by the assigned variable or using `Unit(fromSymbol: String)`. - -To simplify access, `Unit` may be extended with a static property: - -```swift -extension Unit { - public static var centifoot = try! Unit.fromSymbol("cft") -} - -let measurement = 5.measured(in: .centifoot) -``` - -Again, unless strictly necessary, `Unit.define` is preferred over `Unit.register`. - ## CLI The easiest way to install the CLI is with brew: diff --git a/Sources/CLI/Convert.swift b/Sources/CLI/Convert.swift index ef79b10..5540df2 100644 --- a/Sources/CLI/Convert.swift +++ b/Sources/CLI/Convert.swift @@ -39,6 +39,8 @@ struct Convert: ParsableCommand { } } +let registry = Units.Registry.default + extension Expression: @retroactive ExpressibleByArgument { public convenience init?(argument: String) { let argument = argument.replacingOccurrences(of: "_", with: " ") @@ -48,9 +50,9 @@ extension Expression: @retroactive ExpressibleByArgument { extension Units.Unit: @retroactive ExpressibleByArgument { public init?(argument: String) { - if let unit = try? Self(fromName: argument) { + if let unit = try? Self(fromName: argument, registry: registry) { self = unit - } else if let unit = try? Self(fromSymbol: argument) { + } else if let unit = try? Self(fromSymbol: argument, registry: registry) { self = unit } else { return nil diff --git a/Sources/CLI/List.swift b/Sources/CLI/List.swift index cb31fc7..1338b82 100644 --- a/Sources/CLI/List.swift +++ b/Sources/CLI/List.swift @@ -7,7 +7,7 @@ struct List: ParsableCommand { ) func run() throws { - let units = Units.Unit.allDefined().sorted { u1, u2 in + let units = registry.allUnits().sorted { u1, u2 in u1.name <= u2.name } diff --git a/Sources/Units/Expression.swift b/Sources/Units/Expression.swift index 391c8df..46fec33 100644 --- a/Sources/Units/Expression.swift +++ b/Sources/Units/Expression.swift @@ -27,8 +27,8 @@ public final class Expression { /// - `5m^2/s + (1m + 2m)^2 / 5s` /// /// - Parameter expr: The string expression to parse. - public init(_ expr: String) throws { - let parsed = try Parser(expr).parseExpression() + public init(_ expr: String, registry: Registry = .default) throws { + let parsed = try Parser(expr, registry: registry).parseExpression() first = parsed.first last = parsed.last count = parsed.count diff --git a/Sources/Units/Measurement/Measurement.swift b/Sources/Units/Measurement/Measurement.swift index e42add3..c6fa885 100644 --- a/Sources/Units/Measurement/Measurement.swift +++ b/Sources/Units/Measurement/Measurement.swift @@ -165,9 +165,9 @@ extension Measurement: CustomStringConvertible { } } -extension Measurement: LosslessStringConvertible { - public init?(_ description: String) { - guard let parsed = try? Parser(description).parseMeasurement() else { +public extension Measurement { + init?(_ description: String, registry _: Registry = .default) { + guard let parsed = try? Parser(description, registry: .default).parseMeasurement() else { return nil } value = parsed.value diff --git a/Sources/Units/Parser.swift b/Sources/Units/Parser.swift index 4f64d97..d457a09 100644 --- a/Sources/Units/Parser.swift +++ b/Sources/Units/Parser.swift @@ -1,6 +1,7 @@ import Foundation class Parser { + var registry: Registry var data: [UnicodeScalar] var position = 0 @@ -18,8 +19,9 @@ class Parser { return Character(UnicodeScalar(data[position + 1])) } - init(_ string: String) { + init(_ string: String, registry: Registry) { data = Array(string.unicodeScalars) + self.registry = registry } func parseMeasurement() throws -> Measurement { @@ -206,7 +208,7 @@ class Parser { unitString.append(cur) consume() } - let unit = try Unit(fromSymbol: unitString) + let unit = try Unit(fromSymbol: unitString, registry: registry) return .unit(unit) } } diff --git a/Sources/Units/Registry.swift b/Sources/Units/Registry.swift index 34d44a8..13f6d10 100644 --- a/Sources/Units/Registry.swift +++ b/Sources/Units/Registry.swift @@ -1,30 +1,17 @@ /// UnitRegistry defines a structure that contains all defined units. This ensures /// that we are able to parse to and from unit symbol representations. -class Registry { - // TODO: Should we eliminate this singleton and make clients keep track? - static let instance = Registry() +public final class Registry: Sendable { + public static let `default`: Registry = RegistryBuilder().registry() // Quick access based on symbol - private var symbolMap: [String: DefinedUnit] + private let symbolMap: [String: DefinedUnit] // Quick access based on name - private var nameMap: [String: DefinedUnit] + private let nameMap: [String: DefinedUnit] - private init() { - symbolMap = [:] - nameMap = [:] - for defaultUnit in Registry.defaultUnits { - // Protect against double-defining symbols - if symbolMap[defaultUnit.symbol] != nil { - fatalError("Duplicate symbol: \(defaultUnit.symbol)") - } - symbolMap[defaultUnit.symbol] = defaultUnit - - // Protect against double-defining names - if nameMap[defaultUnit.name] != nil { - fatalError("Duplicate name: \(defaultUnit.name)") - } - nameMap[defaultUnit.name] = defaultUnit - } + /// Internal - use the RegistryBuilder to create a new registry. + init(symbolMap: [String: DefinedUnit], nameMap: [String: DefinedUnit]) { + self.symbolMap = symbolMap + self.nameMap = nameMap } /// Returns a list of defined units and their exponents, given a composite unit symbol. It is expected that the caller has @@ -61,323 +48,12 @@ class Registry { return definedUnit } - /// Define a new unit to add to the registry - /// - parameter name: The string name of the unit. - /// - parameter symbol: The string symbol of the unit. Symbols may not contain the characters `*`, `/`, or `^`. - /// - parameter dimension: The unit dimensionality as a dictionary of quantities and their respective exponents. - /// - parameter coefficient: The value to multiply a base unit of this dimension when converting it to this unit. For base units, this is 1. - /// - parameter constant: The value to add to a base unit when converting it to this unit. This is added after the coefficient is multiplied according to order-of-operations. - func addUnit( - name: String, - symbol: String, - dimension: [Quantity: Int], - coefficient: Double = 1, - constant: Double = 0 - ) throws { - let newUnit = try DefinedUnit( - name: name, - symbol: symbol, - dimension: dimension, - coefficient: coefficient, - constant: constant - ) - // Protect against double-defining symbols - if symbolMap[symbol] != nil { - throw UnitError.invalidSymbol(message: "Duplicate symbol: \(symbol)") - } - symbolMap[symbol] = newUnit - - // Protect against double-defining names - if nameMap[name] != nil { - fatalError("Duplicate name: \(name)") - } - nameMap[name] = newUnit - } - /// Returns all units currently defined by the registry - func allUnits() -> [Unit] { + public func allUnits() -> [Unit] { var allUnits = [Unit]() for (_, unit) in symbolMap { allUnits.append(Unit(definedBy: unit)) } return allUnits } - - private static let defaultUnits: [DefinedUnit] = [ - // MARK: Acceleration - - DefaultUnits.standardGravity, - - // MARK: Amount - - DefaultUnits.mole, - DefaultUnits.millimole, - DefaultUnits.particle, - - // MARK: Angle - - DefaultUnits.radian, - DefaultUnits.degree, - DefaultUnits.revolution, - - // MARK: Area - - DefaultUnits.acre, - DefaultUnits.are, - DefaultUnits.hectare, - - // MARK: Capacitance - - DefaultUnits.farad, - - // MARK: Charge - - DefaultUnits.coulomb, - - // MARK: Current - - DefaultUnits.ampere, - DefaultUnits.microampere, - DefaultUnits.milliampere, - DefaultUnits.kiloampere, - DefaultUnits.megaampere, - - // MARK: Data - - DefaultUnits.bit, - DefaultUnits.kilobit, - DefaultUnits.megabit, - DefaultUnits.gigabit, - DefaultUnits.terabit, - DefaultUnits.petabit, - DefaultUnits.exabit, - DefaultUnits.zetabit, - DefaultUnits.yottabit, - DefaultUnits.kibibit, - DefaultUnits.mebibit, - DefaultUnits.gibibit, - DefaultUnits.tebibit, - DefaultUnits.pebibit, - DefaultUnits.exbibit, - DefaultUnits.zebibit, - DefaultUnits.yobibit, - DefaultUnits.byte, - DefaultUnits.kilobyte, - DefaultUnits.megabyte, - DefaultUnits.gigabyte, - DefaultUnits.terabyte, - DefaultUnits.petabyte, - DefaultUnits.exabyte, - DefaultUnits.zetabyte, - DefaultUnits.yottabyte, - DefaultUnits.kibibyte, - DefaultUnits.mebibyte, - DefaultUnits.gibibyte, - DefaultUnits.tebibyte, - DefaultUnits.pebibyte, - DefaultUnits.exbibyte, - DefaultUnits.zebibyte, - DefaultUnits.yobibyte, - - // MARK: Electric Potential Difference - - DefaultUnits.volt, - DefaultUnits.microvolt, - DefaultUnits.millivolt, - DefaultUnits.kilovolt, - DefaultUnits.megavolt, - - // MARK: Energy - - DefaultUnits.joule, - DefaultUnits.kilojoule, - DefaultUnits.megajoule, - DefaultUnits.calorie, - DefaultUnits.kilocalorie, - DefaultUnits.btu, - DefaultUnits.kilobtu, - DefaultUnits.megabtu, - DefaultUnits.therm, - DefaultUnits.electronVolt, - - // MARK: Force - - DefaultUnits.newton, - DefaultUnits.poundForce, - - // MARK: Frequency - - DefaultUnits.hertz, - DefaultUnits.nanohertz, - DefaultUnits.microhertz, - DefaultUnits.millihertz, - DefaultUnits.kilohertz, - DefaultUnits.megahertz, - DefaultUnits.gigahertz, - DefaultUnits.terahertz, - - // MARK: Illuminance - - DefaultUnits.lux, - DefaultUnits.footCandle, - DefaultUnits.phot, - - // MARK: Inductance - - DefaultUnits.henry, - - // MARK: Length - - DefaultUnits.meter, - DefaultUnits.picometer, - DefaultUnits.nanoometer, - DefaultUnits.micrometer, - DefaultUnits.millimeter, - DefaultUnits.centimeter, - DefaultUnits.decameter, - DefaultUnits.hectometer, - DefaultUnits.kilometer, - DefaultUnits.megameter, - DefaultUnits.inch, - DefaultUnits.foot, - DefaultUnits.yard, - DefaultUnits.mile, - DefaultUnits.scandanavianMile, - DefaultUnits.nauticalMile, - DefaultUnits.fathom, - DefaultUnits.furlong, - DefaultUnits.astronomicalUnit, - DefaultUnits.lightyear, - DefaultUnits.parsec, - - // MARK: Luminous Intensity - - DefaultUnits.candela, - - // MARK: Luminous Flux - - DefaultUnits.lumen, - - // MARK: Magnetic Flux - - DefaultUnits.weber, - - // MARK: Magnetic Flux Density - - DefaultUnits.tesla, - - // MARK: Mass - - DefaultUnits.kilogram, - DefaultUnits.picogram, - DefaultUnits.nanogram, - DefaultUnits.microgram, - DefaultUnits.milligram, - DefaultUnits.centigram, - DefaultUnits.decigram, - DefaultUnits.gram, - DefaultUnits.metricTon, - DefaultUnits.carat, - DefaultUnits.ounce, - DefaultUnits.pound, - DefaultUnits.stone, - DefaultUnits.shortTon, - DefaultUnits.troyOunces, - DefaultUnits.slug, - - // MARK: Power - - DefaultUnits.watt, - DefaultUnits.femptowatt, - DefaultUnits.picowatt, - DefaultUnits.nanowatt, - DefaultUnits.microwatt, - DefaultUnits.milliwatt, - DefaultUnits.kilowatt, - DefaultUnits.megawatt, - DefaultUnits.gigawatt, - DefaultUnits.terawatt, - DefaultUnits.horsepower, - DefaultUnits.tonRefrigeration, - - // MARK: Pressure - - DefaultUnits.pascal, - DefaultUnits.hectopascal, - DefaultUnits.kilopascal, - DefaultUnits.megapascal, - DefaultUnits.gigapascal, - DefaultUnits.bar, - DefaultUnits.millibar, - DefaultUnits.atmosphere, - DefaultUnits.millimeterOfMercury, - DefaultUnits.centimeterOfMercury, - DefaultUnits.inchOfMercury, - DefaultUnits.centimeterOfWater, - DefaultUnits.inchOfWater, - - // MARK: Resistance - - DefaultUnits.ohm, - DefaultUnits.microohm, - DefaultUnits.milliohm, - DefaultUnits.kiloohm, - DefaultUnits.megaohm, - - // MARK: Solid Angle - - DefaultUnits.steradian, - - // MARK: Temperature - - DefaultUnits.kelvin, - DefaultUnits.celsius, - DefaultUnits.fahrenheit, - DefaultUnits.rankine, - - // MARK: Time - - DefaultUnits.second, - DefaultUnits.nanosecond, - DefaultUnits.microsecond, - DefaultUnits.millisecond, - DefaultUnits.minute, - DefaultUnits.hour, - DefaultUnits.day, - DefaultUnits.week, - DefaultUnits.year, - - // MARK: Velocity - - // Base unit is m/s - DefaultUnits.knots, - - // MARK: Volume - - // Base unit is meter^3 - DefaultUnits.liter, - DefaultUnits.milliliter, - DefaultUnits.centiliter, - DefaultUnits.deciliter, - DefaultUnits.kiloliter, - DefaultUnits.megaliter, - DefaultUnits.teaspoon, - DefaultUnits.tablespoon, - DefaultUnits.fluidOunce, - DefaultUnits.cup, - DefaultUnits.pint, - DefaultUnits.quart, - DefaultUnits.gallon, - DefaultUnits.dryPint, - DefaultUnits.dryQuart, - DefaultUnits.peck, - DefaultUnits.bushel, - DefaultUnits.imperialFluidOunce, - DefaultUnits.imperialCup, - DefaultUnits.imperialPint, - DefaultUnits.imperialQuart, - DefaultUnits.imperialGallon, - DefaultUnits.imperialPeck, - DefaultUnits.metricCup, - ] } diff --git a/Sources/Units/RegistryBuilder.swift b/Sources/Units/RegistryBuilder.swift new file mode 100644 index 0000000..59a5622 --- /dev/null +++ b/Sources/Units/RegistryBuilder.swift @@ -0,0 +1,347 @@ +public class RegistryBuilder { + // Quick access based on symbol + private var symbolMap: [String: DefinedUnit] + // Quick access based on name + private var nameMap: [String: DefinedUnit] + + /// Create a new registry builder. The default units defined by this package are always included. + public init() { + symbolMap = [:] + nameMap = [:] + for defaultUnit in Self.defaultUnits { + // Protect against double-defining symbols + if symbolMap[defaultUnit.symbol] != nil { + fatalError("Duplicate symbol: \(defaultUnit.symbol)") + } + symbolMap[defaultUnit.symbol] = defaultUnit + + // Protect against double-defining names + if nameMap[defaultUnit.name] != nil { + fatalError("Duplicate name: \(defaultUnit.name)") + } + nameMap[defaultUnit.name] = defaultUnit + } + } + + /// Build and return a new registry instance from the current state of the builder. + public func registry() -> Registry { + return Registry( + symbolMap: symbolMap, + nameMap: nameMap + ) + } + + /// Define a new unit to add to the registry + /// - parameter name: The string name of the unit. + /// - parameter symbol: The string symbol of the unit. Symbols may not contain the characters `*`, `/`, or `^`. + /// - parameter dimension: The unit dimensionality as a dictionary of quantities and their respective exponents. + /// - parameter coefficient: The value to multiply a base unit of this dimension when converting it to this unit. For base units, this is 1. + /// - parameter constant: The value to add to a base unit when converting it to this unit. This is added after the coefficient is multiplied according to order-of-operations. + @discardableResult + public func addUnit( + name: String, + symbol: String, + dimension: [Quantity: Int], + coefficient: Double = 1, + constant: Double = 0 + ) throws -> RegistryBuilder { + let newUnit = try DefinedUnit( + name: name, + symbol: symbol, + dimension: dimension, + coefficient: coefficient, + constant: constant + ) + // Protect against double-defining symbols + if symbolMap[symbol] != nil { + throw UnitError.invalidSymbol(message: "Duplicate symbol: \(symbol)") + } + symbolMap[symbol] = newUnit + + // Protect against double-defining names + if nameMap[name] != nil { + fatalError("Duplicate name: \(name)") + } + nameMap[name] = newUnit + + return self + } + + private static let defaultUnits: [DefinedUnit] = [ + // MARK: Acceleration + + DefaultUnits.standardGravity, + + // MARK: Amount + + DefaultUnits.mole, + DefaultUnits.millimole, + DefaultUnits.particle, + + // MARK: Angle + + DefaultUnits.radian, + DefaultUnits.degree, + DefaultUnits.revolution, + + // MARK: Area + + DefaultUnits.acre, + DefaultUnits.are, + DefaultUnits.hectare, + + // MARK: Capacitance + + DefaultUnits.farad, + + // MARK: Charge + + DefaultUnits.coulomb, + + // MARK: Current + + DefaultUnits.ampere, + DefaultUnits.microampere, + DefaultUnits.milliampere, + DefaultUnits.kiloampere, + DefaultUnits.megaampere, + + // MARK: Data + + DefaultUnits.bit, + DefaultUnits.kilobit, + DefaultUnits.megabit, + DefaultUnits.gigabit, + DefaultUnits.terabit, + DefaultUnits.petabit, + DefaultUnits.exabit, + DefaultUnits.zetabit, + DefaultUnits.yottabit, + DefaultUnits.kibibit, + DefaultUnits.mebibit, + DefaultUnits.gibibit, + DefaultUnits.tebibit, + DefaultUnits.pebibit, + DefaultUnits.exbibit, + DefaultUnits.zebibit, + DefaultUnits.yobibit, + DefaultUnits.byte, + DefaultUnits.kilobyte, + DefaultUnits.megabyte, + DefaultUnits.gigabyte, + DefaultUnits.terabyte, + DefaultUnits.petabyte, + DefaultUnits.exabyte, + DefaultUnits.zetabyte, + DefaultUnits.yottabyte, + DefaultUnits.kibibyte, + DefaultUnits.mebibyte, + DefaultUnits.gibibyte, + DefaultUnits.tebibyte, + DefaultUnits.pebibyte, + DefaultUnits.exbibyte, + DefaultUnits.zebibyte, + DefaultUnits.yobibyte, + + // MARK: Electric Potential Difference + + DefaultUnits.volt, + DefaultUnits.microvolt, + DefaultUnits.millivolt, + DefaultUnits.kilovolt, + DefaultUnits.megavolt, + + // MARK: Energy + + DefaultUnits.joule, + DefaultUnits.kilojoule, + DefaultUnits.megajoule, + DefaultUnits.calorie, + DefaultUnits.kilocalorie, + DefaultUnits.btu, + DefaultUnits.kilobtu, + DefaultUnits.megabtu, + DefaultUnits.therm, + DefaultUnits.electronVolt, + + // MARK: Force + + DefaultUnits.newton, + DefaultUnits.poundForce, + + // MARK: Frequency + + DefaultUnits.hertz, + DefaultUnits.nanohertz, + DefaultUnits.microhertz, + DefaultUnits.millihertz, + DefaultUnits.kilohertz, + DefaultUnits.megahertz, + DefaultUnits.gigahertz, + DefaultUnits.terahertz, + + // MARK: Illuminance + + DefaultUnits.lux, + DefaultUnits.footCandle, + DefaultUnits.phot, + + // MARK: Inductance + + DefaultUnits.henry, + + // MARK: Length + + DefaultUnits.meter, + DefaultUnits.picometer, + DefaultUnits.nanoometer, + DefaultUnits.micrometer, + DefaultUnits.millimeter, + DefaultUnits.centimeter, + DefaultUnits.decameter, + DefaultUnits.hectometer, + DefaultUnits.kilometer, + DefaultUnits.megameter, + DefaultUnits.inch, + DefaultUnits.foot, + DefaultUnits.yard, + DefaultUnits.mile, + DefaultUnits.scandanavianMile, + DefaultUnits.nauticalMile, + DefaultUnits.fathom, + DefaultUnits.furlong, + DefaultUnits.astronomicalUnit, + DefaultUnits.lightyear, + DefaultUnits.parsec, + + // MARK: Luminous Intensity + + DefaultUnits.candela, + + // MARK: Luminous Flux + + DefaultUnits.lumen, + + // MARK: Magnetic Flux + + DefaultUnits.weber, + + // MARK: Magnetic Flux Density + + DefaultUnits.tesla, + + // MARK: Mass + + DefaultUnits.kilogram, + DefaultUnits.picogram, + DefaultUnits.nanogram, + DefaultUnits.microgram, + DefaultUnits.milligram, + DefaultUnits.centigram, + DefaultUnits.decigram, + DefaultUnits.gram, + DefaultUnits.metricTon, + DefaultUnits.carat, + DefaultUnits.ounce, + DefaultUnits.pound, + DefaultUnits.stone, + DefaultUnits.shortTon, + DefaultUnits.troyOunces, + DefaultUnits.slug, + + // MARK: Power + + DefaultUnits.watt, + DefaultUnits.femptowatt, + DefaultUnits.picowatt, + DefaultUnits.nanowatt, + DefaultUnits.microwatt, + DefaultUnits.milliwatt, + DefaultUnits.kilowatt, + DefaultUnits.megawatt, + DefaultUnits.gigawatt, + DefaultUnits.terawatt, + DefaultUnits.horsepower, + DefaultUnits.tonRefrigeration, + + // MARK: Pressure + + DefaultUnits.pascal, + DefaultUnits.hectopascal, + DefaultUnits.kilopascal, + DefaultUnits.megapascal, + DefaultUnits.gigapascal, + DefaultUnits.bar, + DefaultUnits.millibar, + DefaultUnits.atmosphere, + DefaultUnits.millimeterOfMercury, + DefaultUnits.centimeterOfMercury, + DefaultUnits.inchOfMercury, + DefaultUnits.centimeterOfWater, + DefaultUnits.inchOfWater, + + // MARK: Resistance + + DefaultUnits.ohm, + DefaultUnits.microohm, + DefaultUnits.milliohm, + DefaultUnits.kiloohm, + DefaultUnits.megaohm, + + // MARK: Solid Angle + + DefaultUnits.steradian, + + // MARK: Temperature + + DefaultUnits.kelvin, + DefaultUnits.celsius, + DefaultUnits.fahrenheit, + DefaultUnits.rankine, + + // MARK: Time + + DefaultUnits.second, + DefaultUnits.nanosecond, + DefaultUnits.microsecond, + DefaultUnits.millisecond, + DefaultUnits.minute, + DefaultUnits.hour, + DefaultUnits.day, + DefaultUnits.week, + DefaultUnits.year, + + // MARK: Velocity + + // Base unit is m/s + DefaultUnits.knots, + + // MARK: Volume + + // Base unit is meter^3 + DefaultUnits.liter, + DefaultUnits.milliliter, + DefaultUnits.centiliter, + DefaultUnits.deciliter, + DefaultUnits.kiloliter, + DefaultUnits.megaliter, + DefaultUnits.teaspoon, + DefaultUnits.tablespoon, + DefaultUnits.fluidOunce, + DefaultUnits.cup, + DefaultUnits.pint, + DefaultUnits.quart, + DefaultUnits.gallon, + DefaultUnits.dryPint, + DefaultUnits.dryQuart, + DefaultUnits.peck, + DefaultUnits.bushel, + DefaultUnits.imperialFluidOunce, + DefaultUnits.imperialCup, + DefaultUnits.imperialPint, + DefaultUnits.imperialQuart, + DefaultUnits.imperialGallon, + DefaultUnits.imperialPeck, + DefaultUnits.metricCup, + ] +} diff --git a/Sources/Units/Unit/DefaultUnits.swift b/Sources/Units/Unit/DefaultUnits.swift index fde7e7e..1cde72d 100644 --- a/Sources/Units/Unit/DefaultUnits.swift +++ b/Sources/Units/Unit/DefaultUnits.swift @@ -2,10 +2,9 @@ import Foundation /// Static type containing this package's pre-defined units enum DefaultUnits { - // MARK: If adding units to this list, add corresponding entries to the following files: - + // If adding units to this list, add corresponding entries to the following files: // - Unit+DefaultUnits.swift - // - Registry.swift + // - RegistryBuilder.swift // - DefinitionTests.swift // MARK: Acceleration diff --git a/Sources/Units/Unit/Unit+DefaultUnits.swift b/Sources/Units/Unit/Unit+DefaultUnits.swift index 0da43d2..6c1ae1b 100644 --- a/Sources/Units/Unit/Unit+DefaultUnits.swift +++ b/Sources/Units/Unit/Unit+DefaultUnits.swift @@ -2,8 +2,12 @@ // - Concentration Mass // - Dispersion +// Provided for easy access to default units public extension Unit { - // Provided as easy access to UnitRegistry default units + // If adding units to this list, add corresponding entries to the following files: + // - DefaultUnits.swift + // - RegistryBuilder.swift + // - DefinitionTests.swift // MARK: Acceleration diff --git a/Sources/Units/Unit/Unit.swift b/Sources/Units/Unit/Unit.swift index 5092774..5265cf6 100644 --- a/Sources/Units/Unit/Unit.swift +++ b/Sources/Units/Unit/Unit.swift @@ -6,9 +6,8 @@ import Foundation /// Units may be multiplied and divided, resulting in "composite" units, which retain all the characteristics /// of a basic, predefined unit. /// -/// This type is backed by a global registry that allows units to be encoded and decoded using their symbol. /// It also is given a large number of static members for easy access to this package's predefined units. -public struct Unit { +public struct Unit: Sendable { private let type: UnitType /// Singleton representing the lack of a unit @@ -16,33 +15,33 @@ public struct Unit { Unit(type: .none) } - /// Create a unit from the symbol. This symbol is compared to the global registry, decomposed if necessary, + /// Create a unit from the symbol. This symbol is compared to the registry, decomposed if necessary, /// and the relevant unit is initialized. /// - Parameter symbol: A string defining the unit to retrieve. This can be the symbol of a defined unit /// or a complex unit symbol that combines basic units with `*`, `/`, or `^`. - public init(fromSymbol symbol: String) throws { + public init(fromSymbol symbol: String, registry: Registry = .default) throws { let symbolContainsOperator = OperatorSymbols.allCases.contains { arithSymbol in symbol.contains(arithSymbol.rawValue) } if symbolContainsOperator { - let compositeUnits = try Registry.instance.compositeUnitsFromSymbol(symbol: symbol) + let compositeUnits = try registry.compositeUnitsFromSymbol(symbol: symbol) if compositeUnits.isEmpty { self = .none } else { self.init(composedOf: compositeUnits) } } else { - let definedUnit = try Registry.instance.getUnit(bySymbol: symbol) + let definedUnit = try registry.getUnit(bySymbol: symbol) self.init(definedBy: definedUnit) } } - /// Retrieve a unit by name. This name is compared to the global registry and the relevant unit is initialized. + /// Retrieve a unit by name. This name is compared to the registry and the relevant unit is initialized. /// Only defined units are returned - complex unit name equations are not supported. /// /// - Parameter symbol: A string name of the unit to retrieve. This cannot be a complex equation of names. - public init(fromName name: String) throws { - let definedUnit = try Registry.instance.getUnit(byName: name) + public init(fromName name: String, registry: Registry = .default) throws { + let definedUnit = try registry.getUnit(byName: name) self.init(definedBy: definedUnit) } @@ -67,82 +66,6 @@ public struct Unit { self.type = type } - /// Define a unit extension without adding it to the registry. The resulting unit object should be retained - /// and passed to the callers that may want to use it. - /// - /// This unit can be used for arithmatic, conversions, and is encoded correctly. However, since it is - /// not part of the global registry it will not be decoded, will not be included in the `allDefined()` - /// method, and cannot not be retrieved using `Unit(fromSymbol:)`. - /// - /// This method is considered "safe" because it does not modify the global unit registry. - /// - /// - Parameters: - /// - name: The string name of the unit. - /// - symbol: The string symbol of the unit. Symbols may not contain the characters `*`, `/`, or `^`. - /// - dimension: The unit dimensionality as a dictionary of quantities and their respective exponents. - /// - coefficient: The value to multiply a base unit of this dimension when converting it to this unit. - /// For base units, this is 1. - /// - constant: The value to add to a base unit when converting it to this unit. For units without scaling - /// differences, this is 0. This is added after the coefficient is multiplied according to order-of-operations. - /// - Returns: The unit that was defined. - public static func define( - name: String, - symbol: String, - dimension: [Quantity: Int], - coefficient: Double = 1, - constant: Double = 0 - ) throws -> Unit { - return try Unit( - definedBy: .init( - name: name, - symbol: symbol, - dimension: dimension, - coefficient: coefficient, - constant: constant - ) - ) - } - - /// **Careful!** Register a new unit to the global registry. Unless you need deserialization support for this unit, - /// or support to look up this unit from a different memory-space, we suggest that `define` is used instead. - /// - /// By using this method, the unit is added to the global registry so it will be deserialized correctly, will be included - /// in the `allDefined()` and `Unit(fromSymbol)` methods, and will be available to everyone accessing - /// this package in your runtime environment. - /// - /// - Parameters: - /// - name: The string name of the unit. - /// - symbol: The string symbol of the unit. Symbols may not contain the characters `*`, `/`, or `^`. - /// - dimension: The unit dimensionality as a dictionary of quantities and their respective exponents. - /// - coefficient: The value to multiply a base unit of this dimension when converting it to this unit. - /// For base units, this is 1. - /// - constant: The value to add to a base unit when converting it to this unit. For units without scaling - /// differences, this is 0. This is added after the coefficient is multiplied according to order-of-operations. - /// - Returns: The unit definition that now exists in the registry. - @discardableResult - public static func register( - name: String, - symbol: String, - dimension: [Quantity: Int], - coefficient: Double = 1, - constant: Double = 0 - ) throws -> Unit { - try Registry.instance.addUnit( - name: name, - symbol: symbol, - dimension: dimension, - coefficient: coefficient, - constant: constant - ) - return try Unit(fromSymbol: symbol) - } - - /// Get all defined units - /// - Returns: A list of units representing all that are defined in the registry - public static func allDefined() -> [Unit] { - Registry.instance.allUnits() - } - /// The dimension of the unit in terms of base quanties public var dimension: [Quantity: Int] { switch type { @@ -385,14 +308,14 @@ extension Unit: CustomStringConvertible { } } -extension Unit: LosslessStringConvertible { +public extension Unit { /// Initialize a unit from the provided string. This checks the input against the symbols stored /// in the registry. If no match is found, nil is returned. - public init?(_ description: String) { + init?(_ description: String, registry _: Registry = .default) { if description == "none" { self = .none } else { - guard let unit = try? Unit(fromSymbol: description) else { + guard let unit = try? Unit(fromSymbol: description, registry: .default) else { return nil } self = unit @@ -401,6 +324,10 @@ extension Unit: LosslessStringConvertible { } extension Unit: Codable { + public static var registryUserInfoKey: CodingUserInfoKey { + return CodingUserInfoKey(rawValue: "registry")! + } + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(symbol) @@ -408,8 +335,7 @@ extension Unit: Codable { public init(from: Decoder) throws { let symbol = try from.singleValueContainer().decode(String.self) - try self.init(fromSymbol: symbol) + let registry = from.userInfo[Self.registryUserInfoKey] as? Registry ?? .default + try self.init(fromSymbol: symbol, registry: registry) } } - -extension Unit: Sendable {} diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index c26f943..2ae442c 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -362,13 +362,15 @@ final class MeasurementTests: XCTestCase { ) } - func testUnitDefine() throws { - let centifoot = try Unit.define( + func testRegistryAddUnits() throws { + let registryBuilder = RegistryBuilder() + var registry = try registryBuilder.addUnit( name: "centifoot", symbol: "cft", dimension: [.Length: 1], coefficient: 0.003048 - ) + ).registry() + let centifoot = try Unit(fromSymbol: "cft", registry: registry) // Test conversion from custom unit XCTAssertEqual( @@ -384,12 +386,13 @@ final class MeasurementTests: XCTestCase { accuracy: accuracy ) - let centiinch = try Unit.define( + registry = try registryBuilder.addUnit( name: "centiinch", symbol: "cin", dimension: [.Length: 1], coefficient: 0.000254 - ) + ).registry() + let centiinch = try Unit(fromSymbol: "cin", registry: registry) // Test conversion from a custom unit to a different custom unit XCTAssertEqual( @@ -400,7 +403,7 @@ final class MeasurementTests: XCTestCase { // Test that definitions with bad characters are rejected XCTAssertThrowsError( - try Unit.define( + try registryBuilder.addUnit( name: "no name", symbol: "", dimension: [.Amount: 1], @@ -408,7 +411,7 @@ final class MeasurementTests: XCTestCase { ) ) XCTAssertThrowsError( - try Unit.define( + try registryBuilder.addUnit( name: "unit with space", symbol: "unit with space", dimension: [.Amount: 1], @@ -416,7 +419,7 @@ final class MeasurementTests: XCTestCase { ) ) XCTAssertThrowsError( - try Unit.define( + try registryBuilder.addUnit( name: "slash", symbol: "/", dimension: [.Amount: 1], @@ -424,7 +427,7 @@ final class MeasurementTests: XCTestCase { ) ) XCTAssertThrowsError( - try Unit.define( + try registryBuilder.addUnit( name: "star", symbol: "*", dimension: [.Amount: 1], @@ -432,7 +435,7 @@ final class MeasurementTests: XCTestCase { ) ) XCTAssertThrowsError( - try Unit.define( + try registryBuilder.addUnit( name: "carrot", symbol: "^", dimension: [.Amount: 1], @@ -477,27 +480,36 @@ final class MeasurementTests: XCTestCase { } func testUnitRegister() throws { - try Unit.register( + let registryBuilder = RegistryBuilder() + + try registryBuilder.addUnit( name: "centiinch", symbol: "cin", dimension: [.Length: 1], coefficient: 0.000254 ) + let registry = registryBuilder.registry() + // Test referencing string before running the extension XCTAssertEqual( - try 25.measured(in: Unit(fromSymbol: "cin")).convert(to: .inch), + try 25.measured(in: Unit(fromSymbol: "cin", registry: registry)).convert(to: .inch), 0.25.measured(in: .inch), accuracy: accuracy ) + + let centiinch = try XCTUnwrap( + Unit(fromName: "centiinch", registry: registry) + ) + // Test typical usage XCTAssertEqual( - try 25.measured(in: .centiinch).convert(to: .inch), + try 25.measured(in: centiinch).convert(to: .inch), 0.25.measured(in: .inch), accuracy: accuracy ) // Try using twice to verify that multiple access doesn't error XCTAssertEqual( - try 100.measured(in: .centiinch).convert(to: .inch), + try 100.measured(in: centiinch).convert(to: .inch), 1.measured(in: .inch), accuracy: accuracy ) @@ -545,19 +557,23 @@ final class MeasurementTests: XCTestCase { } func testCustomUnitSystemExample() throws { - let apple = try Unit.define( + let registryBuilder = RegistryBuilder() + try registryBuilder.addUnit( name: "apple", symbol: "apple", dimension: [.Amount: 1], coefficient: 1 ) - - let carton = try Unit.define( + try registryBuilder.addUnit( name: "carton", symbol: "carton", dimension: [.Amount: 1], coefficient: 48 ) + var registry = registryBuilder.registry() + + let apple = try Unit(fromSymbol: "apple", registry: registry) + let carton = try Unit(fromSymbol: "carton", registry: registry) let harvest = 288.measured(in: apple) XCTAssertEqual( @@ -566,12 +582,14 @@ final class MeasurementTests: XCTestCase { accuracy: accuracy ) - let person = try Unit.define( + try registryBuilder.addUnit( name: "person", symbol: "person", dimension: [.Amount: 1], coefficient: 1 ) + registry = registryBuilder.registry() + let person = try Unit(fromSymbol: "person", registry: registry) let personPickRate = 600.measured(in: apple / .day / person) let workforce = 4.measured(in: person) @@ -583,7 +601,3 @@ final class MeasurementTests: XCTestCase { ) } } - -extension Units.Unit { - static let centiinch = try! Unit(fromSymbol: "cin") -} diff --git a/Tests/UnitsTests/ParserTests.swift b/Tests/UnitsTests/ParserTests.swift index 76a2c43..244aae8 100644 --- a/Tests/UnitsTests/ParserTests.swift +++ b/Tests/UnitsTests/ParserTests.swift @@ -2,81 +2,85 @@ import XCTest final class ParseMeasurementTests: XCTestCase { + let registry = Registry.default + func testNoUnit() throws { XCTAssertEqual( - try Parser("5.1").parseMeasurement(), + try Parser("5.1", registry: registry).parseMeasurement(), 5.1.measured(in: .none) ) } func testSimpleUnit() throws { XCTAssertEqual( - try Parser("5.1 kW").parseMeasurement(), + try Parser("5.1 kW", registry: registry).parseMeasurement(), 5.1.measured(in: .kilowatt) ) } func testUnitWithSymbol() throws { XCTAssertEqual( - try Parser("5.1 °F").parseMeasurement(), + try Parser("5.1 °F", registry: registry).parseMeasurement(), 5.1.measured(in: .fahrenheit) ) } func testComplexUnit() throws { XCTAssertEqual( - try Parser("5.1 m^2*kg/s^3").parseMeasurement(), + try Parser("5.1 m^2*kg/s^3", registry: registry).parseMeasurement(), 5.1.measured(in: .meter * .meter * .kilogram / .second / .second / .second) ) } func testHandlesWhitespace() throws { XCTAssertEqual( - try Parser(" 5.1 ").parseMeasurement(), + try Parser(" 5.1 ", registry: registry).parseMeasurement(), 5.1.measured(in: .none) ) XCTAssertEqual( - try Parser("5.1 kW").parseMeasurement(), + try Parser("5.1 kW", registry: registry).parseMeasurement(), 5.1.measured(in: .kilowatt) ) XCTAssertEqual( - try Parser("5.1kW").parseMeasurement(), + try Parser("5.1kW", registry: registry).parseMeasurement(), 5.1.measured(in: .kilowatt) ) } func testHandlesNoDecimal() throws { XCTAssertEqual( - try Parser("5 kW").parseMeasurement(), + try Parser("5 kW", registry: registry).parseMeasurement(), 5.measured(in: .kilowatt) ) } func testFailsOnBadUnit() throws { XCTAssertThrowsError( - try Parser("5 +").parseMeasurement() + try Parser("5 +", registry: registry).parseMeasurement() ) } func testFailsOnUnknownUnit() throws { XCTAssertThrowsError( - try Parser("5 flippers").parseMeasurement() + try Parser("5 flippers", registry: registry).parseMeasurement() ) } func testFailsOnBadValue() throws { XCTAssertThrowsError( - try Parser("orange kW").parseMeasurement() + try Parser("orange kW", registry: registry).parseMeasurement() ) } } final class ParseExpressionTests: XCTestCase { + let registry = Registry.default + func testSimple() throws { XCTAssertEqual( - try Parser("5 m + 3 m").parseExpression(), + try Parser("5 m + 3 m", registry: registry).parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) @@ -84,7 +88,7 @@ final class ParseExpressionTests: XCTestCase { func testComplex() throws { XCTAssertEqual( - try Parser("5 m^2/s + (1 m + 2 m)^2 / 5 s").parseExpression(), + try Parser("5 m^2/s + (1 m + 2 m)^2 / 5 s", registry: registry).parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter * .meter / .second)))) .append(op: .add, node: .init( .subExpression( @@ -99,7 +103,7 @@ final class ParseExpressionTests: XCTestCase { func testNestedExpressions() throws { XCTAssertEqual( - try Parser("5 m * (1 m * (1 m + 2 m))").parseExpression(), + try Parser("5 m * (1 m * (1 m + 2 m))", registry: registry).parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .multiply, node: .init( .subExpression( @@ -117,7 +121,7 @@ final class ParseExpressionTests: XCTestCase { func testNoUnit() throws { XCTAssertEqual( - try Parser("5 + 2 * 3").parseExpression(), + try Parser("5 + 2 * 3", registry: registry).parseExpression(), Expression(node: .init(.measurement(5.measured(in: .none)))) .append(op: .add, node: .init(.measurement(2.measured(in: .none)))) .append(op: .multiply, node: .init(.measurement(3.measured(in: .none)))) @@ -126,13 +130,13 @@ final class ParseExpressionTests: XCTestCase { func testHandlesWhitespace() throws { XCTAssertEqual( - try Parser("5 m + 3 m").parseExpression(), + try Parser("5 m + 3 m", registry: registry).parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) XCTAssertEqual( - try Parser("5m + 3m").parseExpression(), + try Parser("5m + 3m", registry: registry).parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) @@ -140,38 +144,38 @@ final class ParseExpressionTests: XCTestCase { func testFailsOnUnspacedOperators() throws { XCTAssertThrowsError( - try Parser("5m+3m").parseExpression() + try Parser("5m+3m", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser("5m-3m").parseExpression() + try Parser("5m-3m", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser("5m*3m").parseExpression() + try Parser("5m*3m", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser("5m/3m").parseExpression() + try Parser("5m/3m", registry: registry).parseExpression() ) } func testFailsOnIncompleteExpression() throws { XCTAssertThrowsError( - try Parser("5m + ").parseExpression() + try Parser("5m + ", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser("(5m + 2m) - (3m").parseExpression() + try Parser("(5m + 2m) - (3m", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser("(5m)^").parseExpression() + try Parser("(5m)^", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser("(5m + 2m) (3m)").parseExpression() + try Parser("(5m + 2m) (3m)", registry: registry).parseExpression() ) XCTAssertThrowsError( - try Parser(") + (5m + 2m)").parseExpression() + try Parser(") + (5m + 2m)", registry: registry).parseExpression() ) } } diff --git a/Tests/UnitsTests/UnitTests.swift b/Tests/UnitsTests/UnitTests.swift index 0404a07..6d286d2 100644 --- a/Tests/UnitsTests/UnitTests.swift +++ b/Tests/UnitsTests/UnitTests.swift @@ -198,6 +198,7 @@ final class UnitTests: XCTestCase { func testEncode() throws { let encoder = JSONEncoder() + encoder.userInfo[Unit.registryUserInfoKey] = Registry.default XCTAssertEqual( try String(data: encoder.encode(Unit.meter / .second), encoding: .utf8), @@ -207,6 +208,7 @@ final class UnitTests: XCTestCase { func testDecode() throws { let decoder = JSONDecoder() + decoder.userInfo[Unit.registryUserInfoKey] = Registry.default XCTAssertEqual( try decoder.decode(Unit.self, from: "\"m\\/s\"".data(using: .utf8)!), From c939bfc4e1ba9dca1f089a2757c8d96bedf99895 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 1 Sep 2025 23:00:19 -0600 Subject: [PATCH 3/6] build: Enables strict concurrency checks --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index ac39dac..85c8dd1 100644 --- a/Package.swift +++ b/Package.swift @@ -37,5 +37,6 @@ let package = Package( name: "PerformanceTests", dependencies: ["Units"] ), - ] + ], + swiftLanguageVersions: [.v5, .version("6")] ) From 23676a9649831697bb55903f606641f11989e1e2 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 1 Sep 2025 23:14:19 -0600 Subject: [PATCH 4/6] docs: Adds migration instructions --- Migration.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Migration.md diff --git a/Migration.md b/Migration.md new file mode 100644 index 0000000..a5793a8 --- /dev/null +++ b/Migration.md @@ -0,0 +1,50 @@ +# v0 to v1 + +## Registry singleton removal + +To avoid data races, the internal `Registry` singleton has been removed, instead preferring an explicit user-defined `Registry`. For example, when parsing or querying units from Strings, a registry instance should be passed: + +```swift +let meter = try Unit(fromSymbol: "m") // old +let meter = try Unit(fromSymbol: "m", registry: registry) // new +``` + +Note that if registry is omitted from these functions, the default unit database is used, which should provide a relatively smooth transition in the common case where custom units are not used. + +## Registry builder + +Registries should be defined and instantiated during startup, and must not be changed after creation. To enforce this lifecycle, a `RegistryBuilder` has been introduced that custom units may be registered to. + +```swift +// old +let centifoot = try Unit.define( + name: "centifoot", + symbol: "cft", + dimension: [.Length: 1], + coefficient: 0.003048 +) + +// new +let registryBuilder = RegistryBuilder() +registryBuilder.addUnit( + name: "centifoot", + symbol: "cft", + dimension: [.Length: 1], + coefficient: 0.003048 +) +let registry = registryBuilder.registry() +``` + +## Registry Encode/Decode support + +To provide `Registry` lookup support inside `Encode`/`Decode` processes, a `userInfo` key has been added: + +```swift +let encoder = JSONEncoder() +encoder.userInfo[Unit.registryUserInfoKey] = Registry.default +try encoder.encode(Unit.meter / .second) + +let decoder = JSONDecoder() +decoder.userInfo[Unit.registryUserInfoKey] = Registry.default +try decoder.decode(Unit.self, from: "\"m\\/s\"".data(using: .utf8)!) +``` From 8727adbf28a691fdd2d983096c561c817d51ebd6 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 1 Sep 2025 23:17:07 -0600 Subject: [PATCH 5/6] ci: Fix stale CI definitions Also test a much larger linux matrix --- .github/workflows/test.yml | 41 +++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45e3acd..8a9cb17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,15 +4,38 @@ on: push: { branches: [ main ] } jobs: - test: + macos: + name: Test on macOS + runs-on: macos-latest + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: actions/checkout@v4 + - name: Build and test + run: | + swift test \ + --parallel \ + --skip PerformanceTests + + linux: + name: Test on Linux - ${{ matrix.swift-image }} strategy: matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + swift-image: + - "swift:5.10-jammy" + - "swift:5.10-noble" + - "swift:6.0-jammy" + - "swift:6.0-noble" + - "swift:6.1-jammy" + - "swift:6.1-noble" + runs-on: ubuntu-latest + container: ${{ matrix.swift-image }} steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Swift - uses: swift-actions/setup-swift@v2 - - name: Run tests - run: swift test --skip PerformanceTests + - name: Checkout + uses: actions/checkout@v4 + - name: Test + run: | + swift test \ + --parallel \ + --skip PerformanceTests From 170f963a89ddb5c0a516952172393c658e35cad8 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Mon, 1 Sep 2025 23:24:37 -0600 Subject: [PATCH 6/6] fix: Swift 5.10 back-support --- Sources/CLI/Convert.swift | 4 ++-- Tests/UnitsTests/MeasurementTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CLI/Convert.swift b/Sources/CLI/Convert.swift index 5540df2..e369493 100644 --- a/Sources/CLI/Convert.swift +++ b/Sources/CLI/Convert.swift @@ -41,14 +41,14 @@ struct Convert: ParsableCommand { let registry = Units.Registry.default -extension Expression: @retroactive ExpressibleByArgument { +extension Units.Expression: ArgumentParser.ExpressibleByArgument { public convenience init?(argument: String) { let argument = argument.replacingOccurrences(of: "_", with: " ") try? self.init(argument) } } -extension Units.Unit: @retroactive ExpressibleByArgument { +extension Units.Unit: ArgumentParser.ExpressibleByArgument { public init?(argument: String) { if let unit = try? Self(fromName: argument, registry: registry) { self = unit diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index 2ae442c..cbb59d8 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -39,7 +39,7 @@ final class MeasurementTests: XCTestCase { // Test that adding different units of the same dimension works XCTAssertEqual( try 5.measured(in: .meter) + 5.measured(in: .millimeter), - 5.005.measured(in: .meter), + 5.005.measured(in: .meter) ) // Test that adding different dimensions throws an error @@ -72,7 +72,7 @@ final class MeasurementTests: XCTestCase { // Test that subtracting different units of the same dimension works XCTAssertEqual( try 5.measured(in: .meter) - 5.measured(in: .millimeter), - 4.995.measured(in: .meter), + 4.995.measured(in: .meter) ) // Test that subtracting different dimensions throws an error