Skip to content

Commit

Permalink
Merge pull request #12 from NeedleInAJayStack/feature/cli-expressions
Browse files Browse the repository at this point in the history
Adds Expression parsing, solving, and CLI support
  • Loading branch information
NeedleInAJayStack authored Jun 27, 2024
2 parents 6d50f8e + df2f506 commit bc4481b
Show file tree
Hide file tree
Showing 7 changed files with 956 additions and 30 deletions.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,20 @@ Here are a few examples:
- `m/s*kg`: equivalent to `kg*m/s`
- `m^1*s^-1*kg^1`: equivalent to `kg*m/s`

Measurements are represented as the numeric value followed by a space, then the serialized unit. For example, `5 m/s`
Measurements are represented as the numeric value followed by the serialized unit with an optional space. For example, `5m/s` or `5 m/s`.

Expressions are a mathematical combination of measurements. Arithemetic operators, exponents, and sub-expressions are supported. Here are a few expression examples:

- `5m + 3m`
- `5.3 m + 3.8 m`
- `5m^2/s + (1m + 2m)^2 / 5s`

There are few expression parsing rules to keep in mind:

- All parentheses must be matched
- All measurement operators must have a leading and following space. i.e. ` * `
- Only integer exponentiation is supported
- Exponentiated measurements must have parentheses to avoid ambiguity with units. i.e. `(3m)^2`

## Default Units

Expand Down Expand Up @@ -236,13 +249,19 @@ To uninstall, run:
You can then perform unit conversions using the `unit convert` command:

```bash
unit convert 5_m/s mi/hr # Returns 11.184681460272012 mi/hr
unit convert 5m/s mi/hr # Returns 11.184681460272012 mi/hr
```

This command uses the unit and measurement [serialization format](#serialization). Note that for
This command uses the unit and expression [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.

You can also evaulate math in the first argument. For example:

```bash
unit convert "60mi/hr * 30min" "mi" # Returns 30.0 mi
```

### List

To list the available units, use the `unit list` command:
Expand Down
24 changes: 11 additions & 13 deletions Sources/CLI/Convert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Units

struct Convert: ParsableCommand {
static var configuration = CommandConfiguration(
abstract: "Convert a measurement to a specified unit.",
abstract: "Convert a measurement expression to a specified unit.",
discussion: """
Run `unit list` to see the supported unit symbols and names. Unless arguments are wrapped \
in quotes, the `*` character may need to be escaped.
Expand All @@ -12,20 +12,21 @@ struct Convert: ParsableCommand {
https://github.com/NeedleInAJayStack/Units/blob/main/README.md#serialization
EXAMPLES:
unit convert 1_ft m
unit convert 1ft m
unit convert 1_ft meter
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
unit convert "8kg * 3m / 2s^2" "N"
"""
)

@Argument(help: """
The measurement to convert. This is a number, followed by a space, followed by a unit \
symbol. For convenience, you may use an underscore `_` to represent the space.
The expression to compute to convert. This must follow the expression parsing rules found \
in https://github.com/NeedleInAJayStack/Units/blob/main/README.md#serialization. \
For convenience, you may use an underscore `_` to represent spaces.
""")
var from: Measurement
var from: Expression

@Argument(help: """
The unit to convert to. This can either be a unit name, a unit symbol, or an equation of \
Expand All @@ -34,17 +35,14 @@ struct Convert: ParsableCommand {
var to: Units.Unit

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

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

Expand Down
276 changes: 276 additions & 0 deletions Sources/Units/Expression.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
/// Represents a mathematical expression of measurements. It supports arithemetic operators, exponents, and sub-expressions.
public final class Expression {
// Implemented as a linked list of ExpressionNodes. This allows us to indicate operators,
// and iteratively solve by reducing the list according to the order of operations.

var first: ExpressionNode
var last: ExpressionNode
var count: Int

init(node: ExpressionNode) {
self.first = node
self.last = node
count = 1
}

/// Initializes an expression from a string.
///
/// Parsing rules:
/// - All parentheses must be matched
/// - All measurement operators must have a leading and following space. i.e. ` * `
/// - Only integer exponentiation is supported
/// - Exponentiated measurements must have parentheses to avoid ambiguity with units. i.e. `(3m)^2`
///
/// Examples:
/// - `5m + 3m`
/// - `5.3 m + 3.8 m`
/// - `5m^2/s + (1m + 2m)^2 / 5s`
///
/// - Parameter expr: The string expression to parse.
public init(_ expr: String) throws {
let parsed = try Parser(expr).parseExpression()
self.first = parsed.first
self.last = parsed.last
self.count = parsed.count
}

/// Reduces the expression to a single measurement, respecting the [order of operations](https://en.wikipedia.org/wiki/Order_of_operations)
public func solve() throws -> Measurement {
let copy = self.copy()
return try copy.computeAndDestroy()
}

@discardableResult
func append(op: Operator, node: ExpressionNode) -> Self {
last.next = .init(op: op, node: node)
last = node
count = count + 1
return self
}

func copy() -> Expression {
// Copy the expression list so the original is not destroyed
let copy = Expression(node: first.copy())
var traversal = first
while let next = traversal.next {
copy.append(op: next.op, node: next.node.copy())
traversal = next.node
}
return copy
}

/// Reduces the expression to a single measurement, respecting the [order of operations](https://en.wikipedia.org/wiki/Order_of_operations)
///
/// NOTE: This flattens the list, destroying it. Use `solve` for non-destructive behavior.
private func computeAndDestroy() throws -> Measurement {

// SubExpressions
func computeSubExpression(node: ExpressionNode) throws {
switch node.value {
case .measurement:
return // Just pass through
case let .subExpression(expression):
// Reassign node's value from subExpression to the solved value
try node.value = .measurement(expression.solve())
}
}
var left = first
while let next = left.next {
try computeSubExpression(node: left)
left = next.node
}
try computeSubExpression(node: left)
// At this point, there should be no more sub expressions

// Exponentals
func exponentiate(node: ExpressionNode) throws {
guard let exponent = node.exponent else {
return
}
switch node.value {
case .subExpression:
fatalError("Parentheses still present during exponent phase")
case let .measurement(measurement):
// Reassign node's value to the exponentiated result & clear exponent
node.value = .measurement(measurement.pow(exponent))
node.exponent = nil
}
}
left = first
while let next = left.next {
try exponentiate(node: left)
left = next.node
}
try exponentiate(node: left)

// Multiplication
left = first
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")
}
}

// Addition
left = first
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: // Compute and absorb right node into left
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)
left.next = right.next
case .multiply, .divide:
fatalError("Multiplication still present during addition phase")
}
default:
fatalError("Parentheses still present during addition phase")
}
}

if first.next != nil {
fatalError("Expression list reduction not complete")
}
switch first.value {
case let .measurement(measurement):
return measurement
default:
fatalError("Final value is not a computed measurement")
}
}
}

extension Expression: CustomStringConvertible {
public var description: String {
var result = first.value.description
var traversal = first
while let next = traversal.next {
result = result + " \(next.op.rawValue) \(next.node.value.description)"
traversal = next.node
}
return result
}
}

extension Expression: Equatable {
public static func == (lhs: Expression, rhs: Expression) -> Bool {
guard lhs.count == rhs.count else {
return false
}
var lhsNode = lhs.first
var rhsNode = rhs.first
guard lhsNode == rhsNode else {
return false
}
while let lhsNext = lhsNode.next, let rhsNext = rhsNode.next {
guard lhsNext == rhsNext else {
return false
}
lhsNode = lhsNext.node
rhsNode = rhsNext.node
}
return true
}
}

class ExpressionNode {
var value: ExpressionNodeValue
var exponent: Int?
var next: ExpressionLink?

init(_ value: ExpressionNodeValue, exponent: Int? = nil, next: ExpressionLink? = nil) {
self.value = value
self.exponent = exponent
self.next = next
}

func copy() -> ExpressionNode {
return .init(value.copy(), exponent: self.exponent)
}
}

extension ExpressionNode: Equatable {
static func == (lhs: ExpressionNode, rhs: ExpressionNode) -> Bool {
return lhs.value == rhs.value &&
lhs.exponent == rhs.exponent
}
}

enum ExpressionNodeValue {
case measurement(Measurement)
case subExpression(Expression)

func copy() -> ExpressionNodeValue {
switch self {
case let .measurement(measurement):
return .measurement(measurement)
case let .subExpression(expression):
return .subExpression(expression.copy())
}
}
}

extension ExpressionNodeValue: CustomStringConvertible {
var description: String {
switch self {
case let .measurement(measurement):
return measurement.description
case let .subExpression(subExpression):
return "(\(subExpression.description))"
}
}
}

extension ExpressionNodeValue: Equatable {
static func == (lhs: ExpressionNodeValue, rhs: ExpressionNodeValue) -> Bool {
switch (lhs, rhs) {
case let (.measurement(lhsM), .measurement(rhsM)):
return lhsM == rhsM
case let (.subExpression(lhsE), .subExpression(rhsE)):
return lhsE == rhsE
default:
return false
}
}
}

class ExpressionLink {
let op: Operator
let node: ExpressionNode

init(op: Operator, node: ExpressionNode) {
self.op = op
self.node = node
}
}

extension ExpressionLink: Equatable {
static func == (lhs: ExpressionLink, rhs: ExpressionLink) -> Bool {
return lhs.op == rhs.op &&
lhs.node == rhs.node
}
}

enum Operator: String {
case add = "+"
case subtract = "-"
case multiply = "*"
case divide = "/"
}
Loading

0 comments on commit bc4481b

Please sign in to comment.