Skip to content
Open
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,55 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week)
print(weeklyCartons) // Prints '350.0 carton/week'
```

### Percent

While technically not a `Measurement`, the use of the percent symbol ('%') is still useful
in conveying the "semanantics" of a scaler value so we include it in this package.

Here’s how math operators work with percentages in typical calculations:

Math operators with percentages treat the percent as its decimal equivalent
(e.g., 25% = 0.25) but in the case of `+` and `-` the calculation is less direct.

#### Multiplication (100 * 25%)

When you multiply a number by a percentage, you’re finding that percent of the number.
• 25% is the same as 0.25.
• So, 100 * 25% = 100 * 0.25 = 25.

#### Division (100 / 30%)

Dividing by a percentage means dividing by its decimal form.
• 30% is 0.3.
• So, 100 / 30% = 100 / 0.3 ≈ 333.33.

#### Addition (100 + 10%)

Adding a percentage to a number is less direct, but usually means increasing the number by that percent.
• 10% of 100 is 10.
• So, 100 + 10% = 100 + (100 * 0.10) = 110.

#### General Rule
• Percent means “per hundred,” so 25% = 25/100 = 0.25.
• Replace the percent with its decimal equivalent before performing the operation.


### FormatStyle

The `Measurement.Formatter` provides the `formatted` method to enable the
setting of the `NumberFormatStyleConfiguration.Precision` for String output.

Example Use:

```
let measure = 28.123.measured(in: .meter)

measure.formatted() // -> "28.123 m"
measure.formatted(minimumFractionDigits: 4) // -> "28.1230 m"
measure.formatted(maximumFractionDigits: 0) // -> "28 m"
measure.formatted(maximumFractionDigits: 1) // -> "28.1 m"
```

## CLI

The easiest way to install the CLI is with brew:
Expand Down
100 changes: 100 additions & 0 deletions Sources/Units/Measurement/Formatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// Formatter.swift
// Units
// (aka Fountation.FormatStyle)
//
// Created by Jason Jobe on 10/24/25.
//

Comment on lines +1 to +8

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this header; we can track dates/contributions using Git.

public extension Measurement {
struct Formatter<Output> {
let format: (Measurement) -> Output
}

func formatted<Output>(_ formatter: Formatter<Output>) -> Output {
formatter.format(self)
}

func formatted(_ formatter: Formatter<String> = .measurement()) -> String {
formatter.format(self)
}

func formatted(
minimumFractionDigits: Int = 0,
maximumFractionDigits: Int = 4
) -> String {
Formatter
.measurement(
minimumFractionDigits: minimumFractionDigits,
maximumFractionDigits: maximumFractionDigits)
.format(self)
}
}
Comment on lines +9 to +32
Copy link
Owner

@NeedleInAJayStack NeedleInAJayStack Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about using Swift's built-in NumberFormatter instead of building our own format system? Example:

// Implementation
extension NumberFormatter {
    func string(from measurement: Measurement) {
        return "\(self.string(from: .init(value: measurement.value)) \(measurement.unit.symbol)"
    }
}

// Usage
let measurement = 28.123.measured(in: .meter)
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 2
print(formatter.string(from: measurement)) // Prints `28.12 m`

This seems like this approach would inherit very configurable options, while also simplifying the implementation and usage. Thoughts?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was leaning into the newer Format API. The custom implementation is limiting. I'll work on that.


extension Measurement.Formatter where Output == String {
public static func measurement(
minimumFractionDigits: Int = 0,
maximumFractionDigits: Int = 4
) -> Self {
self.init { value in
value.value
.formatted(
minimumFractionDigits: minimumFractionDigits,
maximumFractionDigits: maximumFractionDigits)
+ " \(value.unit.symbol)"
}
}
}


// MARK: Percent Formatter

public extension Percent {
struct Formatter<Output> {
let format: (Percent) -> Output
}

func formatted<Output>(_ formatter: Formatter<Output>) -> Output {
formatter.format(self)
}
}

public extension Percent {
func formatted(fractionDigits: Int = 2) -> String {
Formatter(fractionDigits: fractionDigits).format(self)
}
}

public extension Percent.Formatter where Output == String {

init (fractionDigits: Int) {
self.init { value in
(value.magnitude * 100)
.formatted(
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits)
+ value.unit.symbol
}
}
}

public extension BinaryFloatingPoint {
func formatted(minimumFractionDigits: Int = 0, maximumFractionDigits: Int = 4) -> String {
let minDigits = max(0, minimumFractionDigits)
let maxDigits = max(minDigits, maximumFractionDigits)
let s = String(format: "%.\(maxDigits)f", Double(self))
if maxDigits > minDigits, s.contains(".") {
var trimmed = s
while trimmed.last == "0" { trimmed.removeLast() }
if trimmed.last == "." { trimmed.removeLast() }
if let dotIndex = trimmed.firstIndex(of: ".") {
let fractionalCount = trimmed.distance(from: trimmed.index(after: dotIndex), to: trimmed.endIndex)
if fractionalCount < minDigits {
return String(format: "%.\(minDigits)f", Double(self))
}
}
return trimmed
}
return s
}
}
30 changes: 29 additions & 1 deletion Sources/Units/Measurement/Percent+Measurement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import Foundation

If you see a percent sign in a calculation, just convert it to a decimal and proceed as usual. If you want to know how subtraction works with percentages, or how to handle more complex expressions, let me know!
*/
public struct Percent: Numeric, Equatable, Codable {
public struct Percent: Numeric, Codable, Sendable {

public private(set) var magnitude: Double

Expand All @@ -65,6 +65,14 @@ public struct Percent: Numeric, Equatable, Codable {
}
}

extension Percent: Equatable {
/// Implemented as "nearly" equal
public static func ==(lhs: Percent, rhs: Percent) -> Bool {
lhs.magnitude >= rhs.magnitude.nextDown
&& lhs.magnitude <= lhs.magnitude.nextUp
}
}
Comment on lines +68 to +74

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change Equatable implementation to nearly equal? Is this related to the tests that were added?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The significant digits of a "percent" is usually small so representing them with a Double in the arithmetic can lead to values that might result in something like 37.989488484% leading to comparisons that break our intuition (like == 38%). This felt like a reasonable compromise.


extension Measurement {
public var isPercent: Bool {
self.unit == Percent.unit
Expand Down Expand Up @@ -201,3 +209,23 @@ public extension Measurement {
)
}
}

extension Percent: Comparable {
public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.magnitude < rhs.magnitude
}
}

extension Percent {
/**
Returns a random value within the given range.

```
Percent.random(in: 10%...20%)
// 10%, 11%, 12%, 19.98%, etc.
```
*/
public static func random(in range: ClosedRange<Self>) -> Self {
self.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude))
}
}
Comment on lines +219 to +231

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is Percent.random(in: 10%...20%)) a big improvement over Percent(magnitude: .random(in: 10...20))? I'm inclined to think that the existing approach is fine, but I'd be interested in your thoughts.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In one sense, it is just syntactic sugar but it makes the intent clearer and more succinct and encapsulates the internals reducing developer cognitive load.

8 changes: 8 additions & 0 deletions Tests/UnitsTests/MeasurementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -600,4 +600,12 @@ final class MeasurementTests: XCTestCase {
accuracy: accuracy
)
}

func testFormatStyle() {
let measure = 28.123.measured(in: .meter)
XCTAssertEqual(measure.formatted(), "28.123 m")
XCTAssertEqual(measure.formatted(minimumFractionDigits: 4), "28.1230 m")
XCTAssertEqual(measure.formatted(maximumFractionDigits: 0), "28 m")
XCTAssertEqual(measure.formatted(maximumFractionDigits: 1), "28.1 m")
}
}
18 changes: 16 additions & 2 deletions Tests/UnitsTests/PercentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import XCTest

final class PercentTests: XCTestCase {

func testParse() throws {
XCTAssertEqual(
try Expression("10m + 25%"),
Expand Down Expand Up @@ -38,7 +39,20 @@ final class PercentTests: XCTestCase {
try Expression("10m / 25%").solve(),
40.measured(in: .meter)
)

}

func testPercentCalculation() {
XCTAssertEqual(50% * 50%, 25%)
XCTAssertEqual(50% + 5.8%, 55.8%)
XCTAssertEqual(50% - 50%, 0%)
XCTAssertEqual(50% - 5.8%, 44.2%)
}

func testPercentFormat() {
XCTAssertEqual(30%.formatted(), "30%")
XCTAssertEqual(28.5%.formatted(), "28.5%")
XCTAssertEqual(28.33%.formatted(), "28.33%")
XCTAssertEqual(28.33%.formatted(fractionDigits: 1), "28.3%")
XCTAssertEqual(28.33%.formatted(fractionDigits: 0), "28%")
}
}