Skip to content

Commit

Permalink
Merge pull request #5 from NeedleInAJayStack/feature/cli-improvements
Browse files Browse the repository at this point in the history
CLI improvements
  • Loading branch information
NeedleInAJayStack authored Jan 30, 2023
2 parents 9880d3d + 0da3936 commit 9470e2b
Show file tree
Hide file tree
Showing 16 changed files with 312 additions and 171 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let package = Package(
targets: ["Units"]
),
.executable(
name: "convertunit",
name: "unit",
targets: ["CLI"]
),
],
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions Sources/CLI/Convert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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

@Argument(help: "The unit to convert from")
var to: Units.Unit

func run() throws {
try print(from.convert(to: to))
}
}

extension Measurement: ExpressibleByArgument {
public init?(argument: String) {
let argument = argument.replacingOccurrences(of: "_", with: " ")
guard let measurement = Measurement(argument) else {
return nil
}
self = measurement
}
}

extension Units.Unit: ExpressibleByArgument {}
26 changes: 0 additions & 26 deletions Sources/CLI/ConvertUnit.swift

This file was deleted.

61 changes: 61 additions & 0 deletions Sources/CLI/List.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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
}

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)
}
}
}
8 changes: 8 additions & 0 deletions Sources/CLI/Unit.swift
Original file line number Diff line number Diff line change
@@ -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]
)
}
2 changes: 1 addition & 1 deletion Sources/CLI/main.swift
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ConvertUnit.main()
Unit.main()
2 changes: 1 addition & 1 deletion Sources/Units/Quantity.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 8 additions & 25 deletions Sources/Units/Registry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,15 @@ 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 {
guard exponent != 0 else {
continue
}
let definedUnit = try definedUnitFromSymbol(symbol: definedUnitSymbol)
compositeUnits[definedUnit] = exponent
}
return compositeUnits
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Units/Unit/DefaultUnits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ enum DefaultUnits {
name: "particle",
symbol: "particle",
dimension: [.Amount: 1],
coefficient: 6.02214076e23
coefficient: 6.02214076e-23
)

// MARK: Angle
Expand Down
139 changes: 139 additions & 0 deletions Sources/Units/Unit/Equations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/// 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<T>(
of dict: [T: Int],
symbolPath: KeyPath<T, String>,
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")
}

if let existingExp = result[subSymbol] {
result[subSymbol] = existingExp + exp
} else {
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 = "^"
}
7 changes: 0 additions & 7 deletions Sources/Units/Unit/OperatorSymbols.swift

This file was deleted.

Loading

0 comments on commit 9470e2b

Please sign in to comment.