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
41 changes: 32 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions Migration.md
Original file line number Diff line number Diff line change
@@ -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)!)
```
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ let package = Package(
name: "PerformanceTests",
dependencies: ["Units"]
),
]
],
swiftLanguageVersions: [.v5, .version("6")]
)
59 changes: 24 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,40 +121,57 @@ 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))
```

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'
Expand All @@ -163,48 +180,20 @@ 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)
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:
Expand Down
12 changes: 7 additions & 5 deletions Sources/CLI/Convert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -39,18 +39,20 @@ struct Convert: ParsableCommand {
}
}

extension Expression: ExpressibleByArgument {
let registry = Units.Registry.default

extension Units.Expression: ArgumentParser.ExpressibleByArgument {
public convenience init?(argument: String) {
let argument = argument.replacingOccurrences(of: "_", with: " ")
try? self.init(argument)
}
}

extension Units.Unit: ExpressibleByArgument {
extension Units.Unit: ArgumentParser.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
Expand Down
4 changes: 2 additions & 2 deletions Sources/CLI/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ 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."
)

func run() throws {
let units = Units.Unit.allDefined().sorted { u1, u2 in
let units = registry.allUnits().sorted { u1, u2 in
u1.name <= u2.name
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/CLI/Unit.swift
Original file line number Diff line number Diff line change
@@ -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]
)
Expand Down
4 changes: 2 additions & 2 deletions Sources/Units/Expression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Sources/Units/Measurement/Measurement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions Sources/Units/Parser.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

class Parser {
var registry: Registry
var data: [UnicodeScalar]
var position = 0

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading
Loading