From f17f26c0254651a2ccbd7879f81f717646e58124 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 21:43:29 -0700 Subject: [PATCH 1/7] refactor: Centralizes equation serialization/deserialization --- Sources/Units/Registry.swift | 32 +----- Sources/Units/Unit/Equations.swift | 134 +++++++++++++++++++++++ Sources/Units/Unit/OperatorSymbols.swift | 7 -- Sources/Units/Unit/Unit.swift | 113 ++----------------- 4 files changed, 151 insertions(+), 135 deletions(-) create mode 100644 Sources/Units/Unit/Equations.swift delete mode 100644 Sources/Units/Unit/OperatorSymbols.swift diff --git a/Sources/Units/Registry.swift b/Sources/Units/Registry.swift index 1207904..cc47ecd 100644 --- a/Sources/Units/Registry.swift +++ b/Sources/Units/Registry.swift @@ -21,32 +21,12 @@ internal class Registry { /// Returns a list of defined units and their exponents, given a composite unit symbol. It is expected that the caller has /// verified that this is a composite unit. internal func compositeUnitsFromSymbol(symbol: String) throws -> [DefinedUnit: Int] { - var compositeUnits: [DefinedUnit: Int] = [:] - for multSymbol in symbol.split(separator: "*", omittingEmptySubsequences: false) { - for (index, divSymbol) in multSymbol.split(separator: "/", omittingEmptySubsequences: false).enumerated() { - let symbolSplit = divSymbol.split(separator: "^", omittingEmptySubsequences: false) - let subSymbol = String(symbolSplit[0]) - var exp = 1 - if symbolSplit.count == 2 { - guard let expInt = Int(String(symbolSplit[1])) else { - throw UnitError.invalidSymbol(message: "Symbol '^' must be followed by an integer: \(symbol)") - } - exp = expInt - } - if index > 0 { - exp = -1 * exp - } - guard subSymbol != "1" else { - continue - } - guard subSymbol != "" else { - throw UnitError.unitNotFound(message: "Expected subsymbol missing") - } - guard let subUnit = units[subSymbol] else { - throw UnitError.unitNotFound(message: "Symbol '\(subSymbol)' not recognized") - } - compositeUnits[subUnit] = exp - } + let symbolsAndExponents = try deserializeSymbolicEquation(symbol) + + var compositeUnits = [DefinedUnit: Int]() + for (definedUnitSymbol, exponent) in symbolsAndExponents { + let definedUnit = try definedUnitFromSymbol(symbol: definedUnitSymbol) + compositeUnits[definedUnit] = exponent } return compositeUnits } diff --git a/Sources/Units/Unit/Equations.swift b/Sources/Units/Unit/Equations.swift new file mode 100644 index 0000000..b63cc04 --- /dev/null +++ b/Sources/Units/Unit/Equations.swift @@ -0,0 +1,134 @@ +/// Serialize the dictionary of object-exponent pairs into a readable equation, populated +/// with `*` for multiplication, `/` for division, and `^` for exponentiation. For example, +/// given `["a": 1, "b": -2, "c": 3]`, it produces the equation string +/// `a*c^3/b^2`. +/// +/// In the result, the input objects are sorted in the following way: +/// - Positive exponents, from smallest to largest +/// - Negative exponents, from smallest to largest +/// - For equal exponents, objects are in alphabetical order by symbol +/// +/// - Parameters: +/// - dict: The object-exponent pairs +/// - symbolPath: The keypath used to produce String symbols for the objects +/// - spaceAroundOperators: Whether to include space characters before and after multiplication and division characters. +/// - Returns: A string that represents the equation of the object symbols and their respective exponentiation. +func serializeSymbolicEquation( + of dict: [T: Int], + symbolPath: KeyPath, + spaceAroundOperators: Bool = false +) -> String { + // Sort. The order is: + // - Positive exponents, from smallest to largest + // - Negative exponents, from smallest to largest + // - For equal exponents, units are in alphabetical order by symbol + var list = dict.map { object, exp in + (object, exp) + } + list.sort { lhs, rhs in + if lhs.1 > 0, rhs.1 > 0 { + if lhs.1 == rhs.1 { + return lhs.0[keyPath: symbolPath] < rhs.0[keyPath: symbolPath] + } else { + return lhs.1 < rhs.1 + } + } else if lhs.1 > 0, rhs.1 < 0 { + return true + } else if lhs.1 < 0, rhs.1 > 0 { + return false + } else { // lhs.1 < 0 && rhs.1 > 0 + if lhs.1 == rhs.1 { + return lhs.0[keyPath: symbolPath] < rhs.0[keyPath: symbolPath] + } else { + return lhs.1 > rhs.1 + } + } + } + + // Build up equation, using correct operators + let expSymbol = String(OperatorSymbols.exp.rawValue) + var multSymbol = String(OperatorSymbols.mult.rawValue) + var divSymbol = String(OperatorSymbols.div.rawValue) + if spaceAroundOperators { + multSymbol = " \(multSymbol) " + divSymbol = " \(divSymbol) " + } + + var equation = "" + for (object, exp) in list { + guard exp != 0 else { + break + } + + var prefix = "" + if equation == "" { // first symbol + if exp >= 0 { + prefix = "" + } else { + prefix = "1\(divSymbol)" + } + } else { + if exp >= 0 { + prefix = multSymbol + } else { + prefix = divSymbol + } + } + let symbol = object[keyPath: symbolPath] + var expStr = "" + if abs(exp) > 1 { + expStr = "\(expSymbol)\(abs(exp))" + } + + equation += "\(prefix)\(symbol)\(expStr)" + } + return equation +} + +/// Deserialize the equation into a dictionary of String-exponent pairs. +/// This is intended to be used on equations produced by +/// `serializeSymbolicEquation` +/// +/// - Parameter equation: The equation to deserialize +/// - Returns: A dictionary containing object symbols and exponents +func deserializeSymbolicEquation( + _ equation: String +) throws -> [String: Int] { + let expSymbol = OperatorSymbols.exp.rawValue + let multSymbol = OperatorSymbols.mult.rawValue + let divSymbol = OperatorSymbols.div.rawValue + + var result = [String: Int]() + for multChunks in equation.split(separator: multSymbol, omittingEmptySubsequences: false) { + for (index, divChunks) in multChunks.split(separator: divSymbol, omittingEmptySubsequences: false).enumerated() { + let symbolChunks = divChunks.split(separator: expSymbol, omittingEmptySubsequences: false) + let subSymbol = String(symbolChunks[0]).trimmingCharacters(in: .whitespaces) + var exp = 1 + if symbolChunks.count == 2 { + guard let expInt = Int(String(symbolChunks[1])) else { + throw UnitError.invalidSymbol(message: "Symbol '^' must be followed by an integer: \(equation)") + } + exp = expInt + } + if index > 0 { + exp = -1 * exp + } + guard subSymbol != "1" else { + continue + } + guard subSymbol != "" else { + throw UnitError.unitNotFound(message: "Expected subsymbol missing") + } + result[subSymbol] = exp + } + } + return result +} + +/// String operator representations. Since composite units may be parsed from symbols, +/// these must be disallowed in defined unit symbols. +enum OperatorSymbols: Character, CaseIterable { + case mult = "*" + case div = "/" + case exp = "^" +} diff --git a/Sources/Units/Unit/OperatorSymbols.swift b/Sources/Units/Unit/OperatorSymbols.swift deleted file mode 100644 index 0a68545..0000000 --- a/Sources/Units/Unit/OperatorSymbols.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// String operator representations. Since composite units may be parsed from symbols, -/// these must be disallowed in defined unit symbols. -enum OperatorSymbols: String, CaseIterable { - case mult = "*" - case div = "/" - case exp = "^" -} diff --git a/Sources/Units/Unit/Unit.swift b/Sources/Units/Unit/Unit.swift index 89612b2..512245b 100644 --- a/Sources/Units/Unit/Unit.swift +++ b/Sources/Units/Unit/Unit.swift @@ -160,37 +160,11 @@ public struct Unit { return "none" case let .defined(definition): return definition.symbol - case .composite: - let unitList = sortedUnits() - var computedSymbol = "" - for (subUnit, exp) in unitList { - guard exp != 0 else { - break - } - - var prefix = "" - if computedSymbol == "" { // first symbol - if exp >= 0 { - prefix = "" - } else { - prefix = "1\(OperatorSymbols.div.rawValue)" - } - } else { - if exp >= 0 { - prefix = OperatorSymbols.mult.rawValue - } else { - prefix = OperatorSymbols.div.rawValue - } - } - let symbol = subUnit.symbol - var expStr = "" - if abs(exp) > 1 { - expStr = "\(OperatorSymbols.exp.rawValue)\(abs(exp))" - } - - computedSymbol += "\(prefix)\(symbol)\(expStr)" - } - return computedSymbol + case let .composite(subUnits): + return serializeSymbolicEquation( + of: subUnits, + symbolPath: \DefinedUnit.symbol + ) } } @@ -201,37 +175,12 @@ public struct Unit { return "no unit" case let .defined(definition): return definition.name - case .composite: - let unitList = sortedUnits() - var computedName = "" - for (subUnit, exp) in unitList { - guard exp != 0 else { - break - } - - var prefix = "" - if computedName == "" { // first name - if exp >= 0 { - prefix = "" - } else { - prefix = "1 \(OperatorSymbols.div.rawValue) " - } - } else { - if exp >= 0 { - prefix = " \(OperatorSymbols.mult.rawValue) " - } else { - prefix = " \(OperatorSymbols.div.rawValue) " - } - } - let name = subUnit.name - var expStr = "" - if abs(exp) > 1 { - expStr = "\(OperatorSymbols.exp.rawValue)\(abs(exp))" - } - - computedName += "\(prefix)\(name)\(expStr)" - } - return computedName + case let .composite(subUnits): + return serializeSymbolicEquation( + of: subUnits, + symbolPath: \DefinedUnit.name, + spaceAroundOperators: true + ) } } @@ -376,46 +325,6 @@ public struct Unit { } } - /// Sort units into a consistent order. - /// - /// The order is: - /// - Positive exponents, from smallest to largest - /// - Negative exponents, from smallest to largest - /// - For equal exponents, units are in alphabetical order by symbol - private func sortedUnits() -> [(DefinedUnit, Int)] { - switch type { - case .none: - return [] - case let .defined(defined): - return [(defined, 1)] - case let .composite(subUnits): - var unitList = [(DefinedUnit, Int)]() - for (subUnit, exp) in subUnits { - unitList.append((subUnit, exp)) - } - unitList.sort { lhs, rhs in - if lhs.1 > 0, rhs.1 > 0 { - if lhs.1 == rhs.1 { - return lhs.0.symbol < rhs.0.symbol - } else { - return lhs.1 < rhs.1 - } - } else if lhs.1 > 0, rhs.1 < 0 { - return true - } else if lhs.1 < 0, rhs.1 > 0 { - return false - } else { // lhs.1 < 0 && rhs.1 > 0 - if lhs.1 == rhs.1 { - return lhs.0.symbol < rhs.0.symbol - } else { - return lhs.1 > rhs.1 - } - } - } - return unitList - } - } - /// The two possible types of unit - defined or composite private enum UnitType: Sendable { case none From 29b38d43a3cd7d37a088d4f83d18f89136ff85bc Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 21:54:06 -0700 Subject: [PATCH 2/7] feature: Add unit list to CLI Changes `convertunit` to `unit convert` --- Package.swift | 2 +- README.md | 18 ++++-- .../CLI/{ConvertUnit.swift => Convert.swift} | 6 +- Sources/CLI/List.swift | 57 +++++++++++++++++++ Sources/CLI/Unit.swift | 8 +++ Sources/CLI/main.swift | 2 +- Sources/Units/Quantity.swift | 2 +- Sources/Units/Unit/Unit.swift | 7 +++ 8 files changed, 92 insertions(+), 10 deletions(-) rename Sources/CLI/{ConvertUnit.swift => Convert.swift} (83%) create mode 100644 Sources/CLI/List.swift create mode 100644 Sources/CLI/Unit.swift diff --git a/Package.swift b/Package.swift index 4005233..ac39dac 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( targets: ["Units"] ), .executable( - name: "convertunit", + name: "unit", targets: ["CLI"] ), ], diff --git a/README.md b/README.md index 01de24e..7fa8356 100644 --- a/README.md +++ b/README.md @@ -220,20 +220,30 @@ The command-line interface can be built and installed by running the command bel ./install.sh ``` -You can then perform unit conversions using the `convertunit` command: +To uninstall, run: + +```bash +./uninstall.sh +``` + +### Convert + +You can then perform unit conversions using the `unit convert` command: ```bash -convertunit 5_m/s mi/hr # Returns 11.184681460272012 mi/hr +unit convert 5_m/s mi/hr # Returns 11.184681460272012 mi/hr ``` This command uses the unit and measurement [serialization format](#serialization). Note that for convenience, you may use an underscore `_` to represent the normally serialized space. Also, `*` characters may need to be escaped. -To uninstall, run: +### List + +To list the available units, use the `unit list` command: ```bash -./uninstall.sh +unit list ``` ## Contributing diff --git a/Sources/CLI/ConvertUnit.swift b/Sources/CLI/Convert.swift similarity index 83% rename from Sources/CLI/ConvertUnit.swift rename to Sources/CLI/Convert.swift index 96f3a6a..ce3ce14 100644 --- a/Sources/CLI/ConvertUnit.swift +++ b/Sources/CLI/Convert.swift @@ -1,12 +1,12 @@ import ArgumentParser import Units -struct ConvertUnit: ParsableCommand { +struct Convert: ParsableCommand { @Argument(help: "The measurement to convert from") var from: Measurement @Argument(help: "The unit to convert from") - var to: Unit + var to: Units.Unit func run() throws { try print(from.convert(to: to)) @@ -23,4 +23,4 @@ extension Measurement: ExpressibleByArgument { } } -extension Unit: ExpressibleByArgument {} +extension Units.Unit: ExpressibleByArgument {} diff --git a/Sources/CLI/List.swift b/Sources/CLI/List.swift new file mode 100644 index 0000000..adfbd93 --- /dev/null +++ b/Sources/CLI/List.swift @@ -0,0 +1,57 @@ +import ArgumentParser +import Units + +struct List: ParsableCommand { + func run() throws { + let units = Units.Unit.allDefined().sorted { u1, u2 in + u1.name <= u2.name + } + + let columns = [ + "name", + "symbol", + "dimension", + ] + + let rows = units.map { unit in + [ + unit.name, + unit.symbol, + unit.dimensionDescription(), + ] + } + + let padding = 2 // The padding between the longest col value and the start of the next col. + let columnWidths = columns.enumerated().map { i, _ in + rows.reduce(0) { maxSoFar, row in + max(row[i].count, maxSoFar) + } + padding + } + + var header = "" + for (i, column) in columns.enumerated() { + header += column.padding( + toLength: columnWidths[i], + withPad: " ", + startingAt: 0 + ) + } + let rowStrings = rows.map { row in + var rowString = "" + for (i, value) in row.enumerated() { + rowString += value.padding( + toLength: columnWidths[i], + withPad: " ", + startingAt: 0 + ) + } + return rowString + } + + print(header) + print(String(repeating: "-", count: header.count)) + for rowString in rowStrings { + print(rowString) + } + } +} diff --git a/Sources/CLI/Unit.swift b/Sources/CLI/Unit.swift new file mode 100644 index 0000000..05e7505 --- /dev/null +++ b/Sources/CLI/Unit.swift @@ -0,0 +1,8 @@ +import ArgumentParser + +struct Unit: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "A utility for performing unit conversions.", + subcommands: [Convert.self, List.self] + ) +} diff --git a/Sources/CLI/main.swift b/Sources/CLI/main.swift index c594b7b..3a5c9fd 100644 --- a/Sources/CLI/main.swift +++ b/Sources/CLI/main.swift @@ -1 +1 @@ -ConvertUnit.main() +Unit.main() diff --git a/Sources/Units/Quantity.swift b/Sources/Units/Quantity.swift index 91d73a1..0674a91 100644 --- a/Sources/Units/Quantity.swift +++ b/Sources/Units/Quantity.swift @@ -1,5 +1,5 @@ /// A dimension of measurement. These may be combined to form composite dimensions and measurements -public enum Quantity: Sendable { +public enum Quantity: String, Sendable { // TODO: Consider changing away from enum for extensibility // Base ISQ quantities: https://en.wikipedia.org/wiki/International_System_of_Quantities#Base_quantities diff --git a/Sources/Units/Unit/Unit.swift b/Sources/Units/Unit/Unit.swift index 512245b..25a7cdd 100644 --- a/Sources/Units/Unit/Unit.swift +++ b/Sources/Units/Unit/Unit.swift @@ -184,6 +184,13 @@ public struct Unit { } } + public func dimensionDescription() -> String { + return serializeSymbolicEquation( + of: dimension, + symbolPath: \Quantity.rawValue + ) + } + // MARK: - Arithmatic /// Multiply the units. From 698020d03c67cfb36ece595cddf0328b85ef9e97 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 21:54:49 -0700 Subject: [PATCH 3/7] fix: Mol to particle conversion --- Sources/Units/Unit/DefaultUnits.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Units/Unit/DefaultUnits.swift b/Sources/Units/Unit/DefaultUnits.swift index a972a97..116c1db 100644 --- a/Sources/Units/Unit/DefaultUnits.swift +++ b/Sources/Units/Unit/DefaultUnits.swift @@ -32,7 +32,7 @@ enum DefaultUnits { name: "particle", symbol: "particle", dimension: [.Amount: 1], - coefficient: 6.02214076e23 + coefficient: 6.02214076e-23 ) // MARK: Angle From 716a6a1de2dad92bdf4df4c7a7660956b3c9baf0 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 21:59:25 -0700 Subject: [PATCH 4/7] fix: Updates install/uninstall scripts --- install.sh | 2 +- uninstall.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index e63f96b..feed0a0 100755 --- a/install.sh +++ b/install.sh @@ -1,3 +1,3 @@ #!/bin/sh swift build -c release -cp .build/release/convertunit /usr/local/bin/ \ No newline at end of file +cp .build/release/unit /usr/local/bin/ \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh index b3cf06b..3728a87 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,2 +1,2 @@ #!/bin/sh -rm /usr/local/bin/convertunit \ No newline at end of file +rm /usr/local/bin/unit \ No newline at end of file From ea98fdb3b7de840aaf0b034e3b824876ca299956 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 22:19:16 -0700 Subject: [PATCH 5/7] feature: Improves CLI help documentation --- Sources/CLI/Convert.swift | 22 ++++++++++++++++++++++ Sources/CLI/List.swift | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/Sources/CLI/Convert.swift b/Sources/CLI/Convert.swift index ce3ce14..05a5cfd 100644 --- a/Sources/CLI/Convert.swift +++ b/Sources/CLI/Convert.swift @@ -2,6 +2,28 @@ import ArgumentParser import Units struct Convert: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Convert a measurement to a specified unit.", + discussion: """ + This command uses the unit and measurement serialization format. For more details, see + https://github.com/NeedleInAJayStack/Units/blob/main/README.md#serialization + + Note that for convenience, you may use an underscore `_` to represent the normally + serialized space. Also, unless arguments are wrapped in quotes, the `*` character may + need to be escaped. + + Run `unit list` to see the available symbols. + + EXAMPLES: + + unit convert 1_ft m + unit convert 5.4_kW\\*hr J + unit convert 5.4e-3_km/s mi/hr + unit convert "12 kg*m/s^2" "N" + unit convert 12_m^1\\*s^-1\\*kg^1 kg\\*m/s + """ + ) + @Argument(help: "The measurement to convert from") var from: Measurement diff --git a/Sources/CLI/List.swift b/Sources/CLI/List.swift index adfbd93..9084e81 100644 --- a/Sources/CLI/List.swift +++ b/Sources/CLI/List.swift @@ -2,6 +2,10 @@ import ArgumentParser import Units struct List: ParsableCommand { + static var 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 u1.name <= u2.name From abc3d8e06865f3969cefef41178b8715817b129e Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 22:28:43 -0700 Subject: [PATCH 6/7] fix: Equation decoding handles same unit multiple times --- Sources/Units/Unit/Equations.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Units/Unit/Equations.swift b/Sources/Units/Unit/Equations.swift index b63cc04..f2295de 100644 --- a/Sources/Units/Unit/Equations.swift +++ b/Sources/Units/Unit/Equations.swift @@ -119,7 +119,12 @@ func deserializeSymbolicEquation( guard subSymbol != "" else { throw UnitError.unitNotFound(message: "Expected subsymbol missing") } - result[subSymbol] = exp + + if let existingExp = result[subSymbol] { + result[subSymbol] = existingExp + exp + } else { + result[subSymbol] = exp + } } } return result From 0da3936ba7689b61b28db6370152d06250a62055 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 29 Jan 2023 22:29:13 -0700 Subject: [PATCH 7/7] fix: Equation deserialization sets unitless correctly --- Sources/Units/Registry.swift | 3 +++ Sources/Units/Unit/Unit.swift | 6 +++++- Tests/UnitsTests/MeasurementTests.swift | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/Units/Registry.swift b/Sources/Units/Registry.swift index cc47ecd..61fa97e 100644 --- a/Sources/Units/Registry.swift +++ b/Sources/Units/Registry.swift @@ -25,6 +25,9 @@ internal class Registry { var compositeUnits = [DefinedUnit: Int]() for (definedUnitSymbol, exponent) in symbolsAndExponents { + guard exponent != 0 else { + continue + } let definedUnit = try definedUnitFromSymbol(symbol: definedUnitSymbol) compositeUnits[definedUnit] = exponent } diff --git a/Sources/Units/Unit/Unit.swift b/Sources/Units/Unit/Unit.swift index 25a7cdd..c9d718b 100644 --- a/Sources/Units/Unit/Unit.swift +++ b/Sources/Units/Unit/Unit.swift @@ -26,7 +26,11 @@ public struct Unit { } if symbolContainsOperator { let compositeUnits = try Registry.instance.compositeUnitsFromSymbol(symbol: symbol) - self.init(composedOf: compositeUnits) + if compositeUnits.isEmpty { + self = .none + } else { + self.init(composedOf: compositeUnits) + } } else { let definedUnit = try Registry.instance.definedUnitFromSymbol(symbol: symbol) self.init(definedBy: definedUnit) diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index 9ed49a4..6de9580 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -488,6 +488,11 @@ final class MeasurementTests: XCTestCase { XCTAssertNil( Measurement("5 notAUnit") ) + + XCTAssertEqual( + try XCTUnwrap(Measurement("5 m/m")), + 5 + ) } func testCustomUnitSystemExample() throws {