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/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 new file mode 100644 index 0000000..154bee8 --- /dev/null +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -0,0 +1,203 @@ +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 + } +} + +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: DefaultUnits.percent) +} + +// 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 { + /// Adds a percentage to a measurement by increasing its value by the given percent. + /// - Parameters: + /// - 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( + value: lhs.value + rhs.percent(of: lhs.value), + unit: lhs.unit + ) + } + + /// 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( + value: lhs.value - rhs.percent(of: lhs.value), + unit: lhs.unit + ) + } + + /// 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( + value: lhs.value * rhs.magnitude, + 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( + value: lhs.value / rhs.magnitude, + 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) + ) + + } +} + 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,