From 0319a5a64b21c62664795f1dab81de3a5fac0d2a Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Fri, 5 Sep 2025 13:38:03 -0400 Subject: [PATCH 1/2] added Percent (not as Measurement) --- Justfile | 38 ++++ Sources/CLI/Convert.swift | 1 + Sources/CLI/List.swift | 16 +- .../Measurement/Percent+Measurement.swift | 184 ++++++++++++++++++ Tests/UnitsTests/UnitTests.swift | 14 ++ 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 Justfile create mode 100644 Sources/Units/Measurement/Percent+Measurement.swift diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..da3ba45 --- /dev/null +++ b/Justfile @@ -0,0 +1,38 @@ +# Units Justfile +# Command line support + + +# About Units Commands +_default: + @echo '{{ style("warning") }}Units Script Commands{{ NORMAL }}' + @echo @{{source_file()}} + @echo "" + @just -f {{source_file()}} --list + +# Install 'units' in /usr/local/bin +install: + swift build -c release + cp .build/release/unit /usr/local/bin/ + +# rm 'units' from /usr/local/bin +uninstall: + rm /usr/local/bin/unit + +# Add swiftformat to git/pre-commit +dev_setup: + echo "./Scripts/git_commit_hook.sh" > .git/hooks/pre-commit + +# Open Documentation +docs: + open https://swiftpackageindex.com/NeedleInAJayStack/Units/v1.0.0/documentation/units + +git_origin := `git remote get-url origin` + +# repo/origin +repo: + @echo {{git_origin}} + +# open repo/origin +open-repo: + open {{git_origin}} + diff --git a/Sources/CLI/Convert.swift b/Sources/CLI/Convert.swift index e369493..e60d54e 100644 --- a/Sources/CLI/Convert.swift +++ b/Sources/CLI/Convert.swift @@ -31,6 +31,7 @@ struct Convert: ParsableCommand { @Argument(help: """ The unit to convert to. This can either be a unit name, a unit symbol, or an equation of \ unit symbols. + Example: unit convert 1_ft meter -> 0.3048 m """) var to: Units.Unit diff --git a/Sources/CLI/List.swift b/Sources/CLI/List.swift index 1338b82..2ace5e2 100644 --- a/Sources/CLI/List.swift +++ b/Sources/CLI/List.swift @@ -6,6 +6,10 @@ struct List: ParsableCommand { abstract: "Print a table of the available units, their symbols, and their dimensionality." ) + @Option(name: .shortAndLong, + help: "Substring to filter on dimensions and symbols") + var filter: String? = nil + func run() throws { let units = registry.allUnits().sorted { u1, u2 in u1.name <= u2.name @@ -17,7 +21,9 @@ struct List: ParsableCommand { "dimension", ] - let rows = units.map { unit in + let rows = units + .filter({ $0.contains(filter)}) + .map { unit in [ unit.name, unit.symbol, @@ -59,3 +65,11 @@ struct List: ParsableCommand { } } } + +extension Units.Unit { + func contains(_ substring: String?) -> Bool { + guard let substring else { return true } + return self.symbol.contains(substring) + || self.dimensionDescription().contains(substring) + } +} diff --git a/Sources/Units/Measurement/Percent+Measurement.swift b/Sources/Units/Measurement/Percent+Measurement.swift new file mode 100644 index 0000000..36926ab --- /dev/null +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -0,0 +1,184 @@ +// +// Percent.swift +// Units +// +// Created by Jason Jobe on 9/5/25. +// + +import Foundation +/* + NOTE: Should consider introducing `protocol Scalar` + based on `VectorArithmetic` + */ + +/** + 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. + + Here’s how math operators work with percentages in typical calculations: + + 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. + + Example Table + =========== + Expression Decimal Form Result + ------------------------------ + 100 * 25% 100 * 0.25 25 + 100 / 30% 100 / 0.3 333.33 + 100 + 10% 100 + (100*0.10) 110 + + + 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 private(set) var magnitude: Double + + /// Create a new Percent + /// - Parameters: + /// - value: The magnitude of the percent + public init( + magnitude: Double + ) { + self.magnitude = magnitude + } + + func percent(of measure: Measurement) -> Measurement { + .init(value: magnitude * measure.value, unit: measure.unit) + } + + func percent(of other: Double) -> Double { + magnitude * other + } +} + +// MARK: Percent as Unit +extension Percent { + public var unit: Unit { Self.unit } + + public static let unit = Unit( + definedBy: try! DefinedUnit( + name: "percent", + symbol: "%", + dimension: [:], + coefficient: 0.01 + )) +} + +// MARK: Numeric Conformance +public extension Percent { + init(integerLiteral value: Double) { + magnitude = value + } + + static func *= (lhs: inout Percent, rhs: Percent) { + lhs.magnitude *= rhs.magnitude + } + + static func - (lhs: Percent, rhs: Percent) -> Percent { + Percent(magnitude: lhs.magnitude - rhs.magnitude) + } + + init?(exactly source: T) where T : BinaryInteger { + magnitude = Double(source) + } + + static func * (lhs: Percent, rhs: Percent) -> Percent { + Percent(magnitude: lhs.magnitude * rhs.magnitude) + } + + static func + (lhs: Percent, rhs: Percent) -> Percent { + Percent(magnitude: lhs.magnitude + rhs.magnitude) + } +} + +postfix operator % + +extension BinaryInteger { + public static postfix func % (value: Self) -> Percent { + Percent(magnitude: Double(value)/100) + } +} + +extension BinaryFloatingPoint { + public static postfix func % (value: Self) -> Percent { + Percent(magnitude: Double(value)/100) + } +} + +// AdditiveArithmetic operations `*` and `/` + +public extension Measurement { + /// Calculate the percentage of the Measurement + /// - Parameters: + /// - lhs: The left-hand-side measurement + /// - rhs: The right-hand-side measurement + /// - Returns: A new measurement with the summed scalar values and the same unit of measure + @_disfavoredOverload + static func + (lhs: Measurement, rhs: Percent) -> Measurement { + return Measurement( + value: lhs.value + rhs.percent(of: lhs.value), + unit: lhs.unit + ) + } + + @_disfavoredOverload + static func - (lhs: Measurement, rhs: Percent) -> Measurement { + return Measurement( + value: lhs.value - rhs.percent(of: lhs.value), + unit: lhs.unit + ) + } + + @_disfavoredOverload + static func += (lhs: inout Measurement, rhs: Percent) { + lhs = lhs + rhs + } + + @_disfavoredOverload + static func -= (lhs: inout Measurement, rhs: Percent) { + lhs = lhs - rhs + } + +} + +// Scalar operations `*` and `/` +public extension Measurement { + + @_disfavoredOverload + static func * (lhs: Measurement, rhs: Percent) -> Measurement { + return Measurement( + value: lhs.value * rhs.magnitude, + unit: lhs.unit + ) + } + + @_disfavoredOverload + static func / (lhs: Measurement, rhs: Percent) -> Measurement { + return Measurement( + value: lhs.value / rhs.magnitude, + unit: lhs.unit + ) + } + +} diff --git a/Tests/UnitsTests/UnitTests.swift b/Tests/UnitsTests/UnitTests.swift index 6d286d2..b935252 100644 --- a/Tests/UnitsTests/UnitTests.swift +++ b/Tests/UnitsTests/UnitTests.swift @@ -80,6 +80,20 @@ final class UnitTests: XCTestCase { ) } + func testPercent() throws { + let value1 = 100.measured(in: .none) + + XCTAssertEqual(Percent.unit.symbol, "%") + + XCTAssertEqual(1%, 1%) + + XCTAssertEqual(110.measured(in: .none), value1 + 10%) + XCTAssertEqual(90.measured(in: .none), value1 - 10%) + + XCTAssertEqual(10.measured(in: .none), value1 * 10%) + XCTAssertEqual(1_000.measured(in: .none), value1 / 10%) + } + func testSymbol() throws { XCTAssertEqual( Unit.meter.symbol, From 80d55e02130949e41d53357c712e5a048ed4bc4a Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Sat, 13 Sep 2025 22:00:15 -0400 Subject: [PATCH 2/2] integrated Percent into solver and added Unit tests --- Sources/Units/Expression.swift | 48 +++++++++----- .../Measurement/Percent+Measurement.swift | 63 ++++++++++++------- Sources/Units/RegistryBuilder.swift | 3 + Sources/Units/Unit/DefaultUnits.swift | 9 +++ Sources/Units/Unit/Unit+DefaultUnits.swift | 4 ++ Tests/UnitsTests/PercentTests.swift | 44 +++++++++++++ 6 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 Tests/UnitsTests/PercentTests.swift diff --git a/Sources/Units/Expression.swift b/Sources/Units/Expression.swift index 46fec33..13cabb5 100644 --- a/Sources/Units/Expression.swift +++ b/Sources/Units/Expression.swift @@ -107,19 +107,27 @@ public final class Expression { while let next = left.next { let right = next.node switch (left.value, right.value) { - case let (.measurement(leftMeasurement), .measurement(rightMeasurement)): - switch next.op { - case .add, .subtract: // Skip over operation - left = right - case .multiply: // Compute and absorb right node into left - left.value = .measurement(leftMeasurement * rightMeasurement) - left.next = right.next - case .divide: // Compute and absorb right node into left - left.value = .measurement(leftMeasurement / rightMeasurement) - left.next = right.next - } - default: - fatalError("Parentheses still present during multiplication phase") + case let (.measurement(leftMeasurement), .measurement(rightMeasurement)): + switch next.op { + case .add, .subtract: // Skip over operation + left = right + case .multiply: // Compute and absorb right node into left + if let percent = rightMeasurement.asPercent { + left.value = .measurement(leftMeasurement * percent) + } else { + left.value = .measurement(leftMeasurement * rightMeasurement) + } + left.next = right.next + case .divide: // Compute and absorb right node into left + if let percent = rightMeasurement.asPercent { + left.value = .measurement(leftMeasurement / percent) + } else { + left.value = .measurement(leftMeasurement / rightMeasurement) + } + left.next = right.next + } + default: + fatalError("Parentheses still present during multiplication phase") } } @@ -130,11 +138,21 @@ public final class Expression { switch (left.value, right.value) { case let (.measurement(leftMeasurement), .measurement(rightMeasurement)): switch next.op { + case .add: // Compute and absorb right node into left - left.value = try .measurement(leftMeasurement + rightMeasurement) + // NOTE: Exceptional handling of Percent + if let percent = rightMeasurement.asPercent { + left.value = .measurement(leftMeasurement + percent) + } else { + left.value = try .measurement(leftMeasurement + rightMeasurement) + } left.next = right.next case .subtract: // Compute and absorb right node into left - left.value = try .measurement(leftMeasurement - rightMeasurement) + if let percent = rightMeasurement.asPercent { + left.value = .measurement(leftMeasurement - percent) + } else { + left.value = try .measurement(leftMeasurement - rightMeasurement) + } left.next = right.next case .multiply, .divide: fatalError("Multiplication still present during addition phase") diff --git a/Sources/Units/Measurement/Percent+Measurement.swift b/Sources/Units/Measurement/Percent+Measurement.swift index 36926ab..154bee8 100644 --- a/Sources/Units/Measurement/Percent+Measurement.swift +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -1,10 +1,3 @@ -// -// Percent.swift -// Units -// -// Created by Jason Jobe on 9/5/25. -// - import Foundation /* NOTE: Should consider introducing `protocol Scalar` @@ -72,17 +65,22 @@ public struct Percent: Numeric, Equatable, Codable { } } +extension Measurement { + public var isPercent: Bool { + self.unit == Percent.unit + } + + public var asPercent: Percent? { + isPercent ? Percent(magnitude: self.value/100) : nil + } +} + // MARK: Percent as Unit extension Percent { public var unit: Unit { Self.unit } public static let unit = Unit( - definedBy: try! DefinedUnit( - name: "percent", - symbol: "%", - dimension: [:], - coefficient: 0.01 - )) + definedBy: DefaultUnits.percent) } // MARK: Numeric Conformance @@ -129,11 +127,11 @@ extension BinaryFloatingPoint { // AdditiveArithmetic operations `*` and `/` public extension Measurement { - /// Calculate the percentage of the Measurement + /// Adds a percentage to a measurement by increasing its value by the given percent. /// - Parameters: - /// - lhs: The left-hand-side measurement - /// - rhs: The right-hand-side measurement - /// - Returns: A new measurement with the summed scalar values and the same unit of measure + /// - lhs: The base measurement. + /// - rhs: The percentage to add. + /// - Returns: A new `Measurement` with its value increased by the given percentage. @_disfavoredOverload static func + (lhs: Measurement, rhs: Percent) -> Measurement { return Measurement( @@ -142,6 +140,11 @@ public extension Measurement { ) } + /// Subtracts a percentage from a measurement by decreasing its value by the given percent. + /// - Parameters: + /// - lhs: The base measurement. + /// - rhs: The percentage to subtract. + /// - Returns: A new `Measurement` with its value decreased by the given percentage. @_disfavoredOverload static func - (lhs: Measurement, rhs: Percent) -> Measurement { return Measurement( @@ -150,21 +153,33 @@ public extension Measurement { ) } + /// Increases a measurement in place by the given percentage. + /// - Parameters: + /// - lhs: The measurement to modify. + /// - rhs: The percentage to add. @_disfavoredOverload static func += (lhs: inout Measurement, rhs: Percent) { lhs = lhs + rhs } - + + /// Decreases a measurement in place by the given percentage. + /// - Parameters: + /// - lhs: The measurement to modify. + /// - rhs: The percentage to subtract. @_disfavoredOverload static func -= (lhs: inout Measurement, rhs: Percent) { lhs = lhs - rhs } - + } // Scalar operations `*` and `/` public extension Measurement { - + /// Multiplies a measurement by a percentage, treating the percent as a scalar (e.g., 25% = 0.25). + /// - Parameters: + /// - lhs: The base measurement to scale. + /// - rhs: The percentage factor. + /// - Returns: A new `Measurement` whose value is `lhs.value * rhs.magnitude` with the same unit. @_disfavoredOverload static func * (lhs: Measurement, rhs: Percent) -> Measurement { return Measurement( @@ -172,7 +187,12 @@ public extension Measurement { unit: lhs.unit ) } - + + /// Divides a measurement by a percentage, treating the percent as a scalar (e.g., 25% = 0.25). + /// - Parameters: + /// - lhs: The base measurement to scale. + /// - rhs: The percentage divisor. + /// - Returns: A new `Measurement` whose value is `lhs.value / rhs.magnitude` with the same unit. @_disfavoredOverload static func / (lhs: Measurement, rhs: Percent) -> Measurement { return Measurement( @@ -180,5 +200,4 @@ public extension Measurement { unit: lhs.unit ) } - } diff --git a/Sources/Units/RegistryBuilder.swift b/Sources/Units/RegistryBuilder.swift index 59a5622..5f499c2 100644 --- a/Sources/Units/RegistryBuilder.swift +++ b/Sources/Units/RegistryBuilder.swift @@ -249,6 +249,9 @@ public class RegistryBuilder { DefaultUnits.troyOunces, DefaultUnits.slug, + // MARK: Percent + DefaultUnits.percent, + // MARK: Power DefaultUnits.watt, diff --git a/Sources/Units/Unit/DefaultUnits.swift b/Sources/Units/Unit/DefaultUnits.swift index 1cde72d..c856543 100644 --- a/Sources/Units/Unit/DefaultUnits.swift +++ b/Sources/Units/Unit/DefaultUnits.swift @@ -818,6 +818,15 @@ enum DefaultUnits { coefficient: 14.5939 ) + // MARK: Percent + + static let percent = try! DefinedUnit( + name: "percent", + symbol: "%", + dimension: [:], + coefficient: 0.01 + ) + // MARK: Power // Base unit: watt diff --git a/Sources/Units/Unit/Unit+DefaultUnits.swift b/Sources/Units/Unit/Unit+DefaultUnits.swift index 6c1ae1b..8e3c14e 100644 --- a/Sources/Units/Unit/Unit+DefaultUnits.swift +++ b/Sources/Units/Unit/Unit+DefaultUnits.swift @@ -190,6 +190,10 @@ public extension Unit { static let troyOunces = Unit(definedBy: DefaultUnits.troyOunces) static let slug = Unit(definedBy: DefaultUnits.slug) + // MARK: Percent + + static let percent = Unit(definedBy: DefaultUnits.percent) + // MARK: Power static let watt = Unit(definedBy: DefaultUnits.watt) diff --git a/Tests/UnitsTests/PercentTests.swift b/Tests/UnitsTests/PercentTests.swift new file mode 100644 index 0000000..2cd673f --- /dev/null +++ b/Tests/UnitsTests/PercentTests.swift @@ -0,0 +1,44 @@ +// +// Test.swift +// Units +// +// Created by Jason Jobe on 9/13/25. +// + +@testable import Units +import XCTest + +final class PercentTests: XCTestCase { + func testParse() throws { + XCTAssertEqual( + try Expression("10m + 25%"), + Expression(node: .init(.measurement(10.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(25.measured(in: .percent)))) + ) + } + + func testSolutions() throws { + + XCTAssertEqual( + try Expression("10m + 25%").solve(), + 12.5.measured(in: .meter) + ) + + XCTAssertEqual( + try Expression("10m - 25%").solve(), + 7.5.measured(in: .meter) + ) + + XCTAssertEqual( + try Expression("10m * 25%").solve(), + 2.5.measured(in: .meter) + ) + + XCTAssertEqual( + try Expression("10m / 25%").solve(), + 40.measured(in: .meter) + ) + + } +} +