From 0342495f8245917dda267a827a353a8e9a373476 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Wed, 22 Oct 2025 10:31:20 -0400 Subject: [PATCH 1/2] Measurement FormatStyle (#1) * FormatStyle WIP * added FormatStyle tests * updated README and @available --------- Co-authored-by: Jason Jobe --- README.md | 50 ++++++++++++ Sources/Units/Measurement/Measurement.swift | 55 +++++++++++++ .../Measurement/Percent+Measurement.swift | 79 ++++++++++++++++++- Tests/UnitsTests/MeasurementTests.swift | 8 ++ Tests/UnitsTests/PercentTests.swift | 18 ++++- 5 files changed, 207 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 42ce9cd..5a62065 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,56 @@ 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(precision: .significantDigits(1) // -> "30 m" + measure.formatted(precision: .significantDigits(3)) // -> "28.1 m" + measure.formatted(precision: + .integerAndFractionLength(integer: 2, fraction: 0)) // -> "28 m" +``` + ## CLI The easiest way to install the CLI is with brew: diff --git a/Sources/Units/Measurement/Measurement.swift b/Sources/Units/Measurement/Measurement.swift index c6fa885..e74cc5d 100644 --- a/Sources/Units/Measurement/Measurement.swift +++ b/Sources/Units/Measurement/Measurement.swift @@ -194,3 +194,58 @@ extension Measurement: ExpressibleByFloatLiteral { } extension Measurement: Sendable {} + +// MARK: FormatStyle +@available(macOS 12.0, iOS 15.0, *) +public extension Measurement { + + func formatted( + _ style: Style + ) -> Style.FormatOutput where Style.FormatInput == Self { + style.format(self) + } +} + +@available(macOS 12.0, iOS 15.0, *) +public extension Measurement { + typealias Precision = NumberFormatStyleConfiguration.Precision + + struct Formatter { + let format: (Measurement) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } + + func formatted(_ formatter: Formatter = .measurement()) -> String { + formatter.format(self) + } + + @_disfavoredOverload + func formatted(precision: Precision? = nil) -> String { + formatted(.measurement(precision: precision)) + } +} + +@available(macOS 12.0, iOS 15.0, *) +extension Measurement.Formatter where Output == String { + public typealias Precision = NumberFormatStyleConfiguration.Precision + + public static func measurement(precision: Precision? = nil) -> Self { + .init { value in + switch (value.unit, precision) { + case (.none, .none): + "\(value.value.formatted(.number))" + case (.none, .some(let p)): + "\(value.value.formatted(.number.precision(p)))" + case (let u, .none): + "\(value.value.formatted(.number)) \(u)" + + case (let u, .some(let p)): + "\(value.value.formatted(.number.precision(p))) \(u)" + } + } + } +} + diff --git a/Sources/Units/Measurement/Percent+Measurement.swift b/Sources/Units/Measurement/Percent+Measurement.swift index 154bee8..c325987 100644 --- a/Sources/Units/Measurement/Percent+Measurement.swift +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -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 @@ -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 + } +} + extension Measurement { public var isPercent: Bool { self.unit == Percent.unit @@ -201,3 +209,72 @@ 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.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude)) + } +} + +// MARK: FormatStyle +@available(macOS 12.0, iOS 15.0, *) +public extension Percent { + + func formatted( + _ style: Style + ) -> Style.FormatOutput where Style.FormatInput == Self { + style.format(self) + } +} + +public extension Percent { + struct Formatter { + let format: (Percent) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } +} + +@available(macOS 12.0, iOS 15.0, *) +public extension Percent { + func formatted(_ formatter: Formatter = .percent) -> String { + formatter.format(self) + } + + func formatted(fractionDigits: Int) -> String { + Formatter(fractionDigits: fractionDigits).format(self) + } +} + +@available(macOS 12.0, iOS 15.0, *) +public extension Percent.Formatter where Output == String { + + init (fractionDigits: Int) { + self.init { value in + value.magnitude + .formatted(.percent.precision(.fractionLength(fractionDigits))) + } + } + + static var percent: Self { + .init { value in + return value.magnitude.formatted(.percent) + } + } +} diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index cbb59d8..de120fb 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -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(precision: .significantDigits(1)), "30 m") + XCTAssertEqual(measure.formatted(precision: .significantDigits(3)), "28.1 m") + XCTAssertEqual(measure.formatted(precision: .integerAndFractionLength(integer: 2, fraction: 0)), "28 m") + } } diff --git a/Tests/UnitsTests/PercentTests.swift b/Tests/UnitsTests/PercentTests.swift index 2cd673f..f72a679 100644 --- a/Tests/UnitsTests/PercentTests.swift +++ b/Tests/UnitsTests/PercentTests.swift @@ -9,6 +9,7 @@ import XCTest final class PercentTests: XCTestCase { + func testParse() throws { XCTAssertEqual( try Expression("10m + 25%"), @@ -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%") } } - From b1de652971bfc807f7a38ee120b2918ee78cf560 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Sat, 25 Oct 2025 00:06:53 -0400 Subject: [PATCH 2/2] simplified Formatting to support linux --- README.md | 9 +- Sources/Units/Measurement/Formatter.swift | 100 ++++++++++++++++++ Sources/Units/Measurement/Measurement.swift | 55 ---------- .../Measurement/Percent+Measurement.swift | 49 --------- Tests/UnitsTests/MeasurementTests.swift | 6 +- 5 files changed, 107 insertions(+), 112 deletions(-) create mode 100644 Sources/Units/Measurement/Formatter.swift diff --git a/README.md b/README.md index 5a62065..dd64c86 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,10 @@ Example Use: ``` let measure = 28.123.measured(in: .meter) - measure.formatted() // -> "28.123 m" - measure.formatted(precision: .significantDigits(1) // -> "30 m" - measure.formatted(precision: .significantDigits(3)) // -> "28.1 m" - measure.formatted(precision: - .integerAndFractionLength(integer: 2, fraction: 0)) // -> "28 m" + 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 diff --git a/Sources/Units/Measurement/Formatter.swift b/Sources/Units/Measurement/Formatter.swift new file mode 100644 index 0000000..f2dc401 --- /dev/null +++ b/Sources/Units/Measurement/Formatter.swift @@ -0,0 +1,100 @@ +// +// Formatter.swift +// Units +// (aka Fountation.FormatStyle) +// +// Created by Jason Jobe on 10/24/25. +// + +public extension Measurement { + struct Formatter { + let format: (Measurement) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } + + func formatted(_ formatter: Formatter = .measurement()) -> String { + formatter.format(self) + } + + func formatted( + minimumFractionDigits: Int = 0, + maximumFractionDigits: Int = 4 + ) -> String { + Formatter + .measurement( + minimumFractionDigits: minimumFractionDigits, + maximumFractionDigits: maximumFractionDigits) + .format(self) + } +} + +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 { + let format: (Percent) -> Output + } + + func formatted(_ formatter: Formatter) -> 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 + } +} diff --git a/Sources/Units/Measurement/Measurement.swift b/Sources/Units/Measurement/Measurement.swift index e74cc5d..c6fa885 100644 --- a/Sources/Units/Measurement/Measurement.swift +++ b/Sources/Units/Measurement/Measurement.swift @@ -194,58 +194,3 @@ extension Measurement: ExpressibleByFloatLiteral { } extension Measurement: Sendable {} - -// MARK: FormatStyle -@available(macOS 12.0, iOS 15.0, *) -public extension Measurement { - - func formatted( - _ style: Style - ) -> Style.FormatOutput where Style.FormatInput == Self { - style.format(self) - } -} - -@available(macOS 12.0, iOS 15.0, *) -public extension Measurement { - typealias Precision = NumberFormatStyleConfiguration.Precision - - struct Formatter { - let format: (Measurement) -> Output - } - - func formatted(_ formatter: Formatter) -> Output { - formatter.format(self) - } - - func formatted(_ formatter: Formatter = .measurement()) -> String { - formatter.format(self) - } - - @_disfavoredOverload - func formatted(precision: Precision? = nil) -> String { - formatted(.measurement(precision: precision)) - } -} - -@available(macOS 12.0, iOS 15.0, *) -extension Measurement.Formatter where Output == String { - public typealias Precision = NumberFormatStyleConfiguration.Precision - - public static func measurement(precision: Precision? = nil) -> Self { - .init { value in - switch (value.unit, precision) { - case (.none, .none): - "\(value.value.formatted(.number))" - case (.none, .some(let p)): - "\(value.value.formatted(.number.precision(p)))" - case (let u, .none): - "\(value.value.formatted(.number)) \(u)" - - case (let u, .some(let p)): - "\(value.value.formatted(.number.precision(p))) \(u)" - } - } - } -} - diff --git a/Sources/Units/Measurement/Percent+Measurement.swift b/Sources/Units/Measurement/Percent+Measurement.swift index c325987..f18782d 100644 --- a/Sources/Units/Measurement/Percent+Measurement.swift +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -229,52 +229,3 @@ extension Percent { self.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude)) } } - -// MARK: FormatStyle -@available(macOS 12.0, iOS 15.0, *) -public extension Percent { - - func formatted( - _ style: Style - ) -> Style.FormatOutput where Style.FormatInput == Self { - style.format(self) - } -} - -public extension Percent { - struct Formatter { - let format: (Percent) -> Output - } - - func formatted(_ formatter: Formatter) -> Output { - formatter.format(self) - } -} - -@available(macOS 12.0, iOS 15.0, *) -public extension Percent { - func formatted(_ formatter: Formatter = .percent) -> String { - formatter.format(self) - } - - func formatted(fractionDigits: Int) -> String { - Formatter(fractionDigits: fractionDigits).format(self) - } -} - -@available(macOS 12.0, iOS 15.0, *) -public extension Percent.Formatter where Output == String { - - init (fractionDigits: Int) { - self.init { value in - value.magnitude - .formatted(.percent.precision(.fractionLength(fractionDigits))) - } - } - - static var percent: Self { - .init { value in - return value.magnitude.formatted(.percent) - } - } -} diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index de120fb..1e0a746 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -604,8 +604,8 @@ final class MeasurementTests: XCTestCase { func testFormatStyle() { let measure = 28.123.measured(in: .meter) XCTAssertEqual(measure.formatted(), "28.123 m") - XCTAssertEqual(measure.formatted(precision: .significantDigits(1)), "30 m") - XCTAssertEqual(measure.formatted(precision: .significantDigits(3)), "28.1 m") - XCTAssertEqual(measure.formatted(precision: .integerAndFractionLength(integer: 2, fraction: 0)), "28 m") + XCTAssertEqual(measure.formatted(minimumFractionDigits: 4), "28.1230 m") + XCTAssertEqual(measure.formatted(maximumFractionDigits: 0), "28 m") + XCTAssertEqual(measure.formatted(maximumFractionDigits: 1), "28.1 m") } }