Skip to content

Commit bc4481b

Browse files
Merge pull request #12 from NeedleInAJayStack/feature/cli-expressions
Adds Expression parsing, solving, and CLI support
2 parents 6d50f8e + df2f506 commit bc4481b

7 files changed

Lines changed: 956 additions & 30 deletions

File tree

README.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,20 @@ Here are a few examples:
117117
- `m/s*kg`: equivalent to `kg*m/s`
118118
- `m^1*s^-1*kg^1`: equivalent to `kg*m/s`
119119

120-
Measurements are represented as the numeric value followed by a space, then the serialized unit. For example, `5 m/s`
120+
Measurements are represented as the numeric value followed by the serialized unit with an optional space. For example, `5m/s` or `5 m/s`.
121+
122+
Expressions are a mathematical combination of measurements. Arithemetic operators, exponents, and sub-expressions are supported. Here are a few expression examples:
123+
124+
- `5m + 3m`
125+
- `5.3 m + 3.8 m`
126+
- `5m^2/s + (1m + 2m)^2 / 5s`
127+
128+
There are few expression parsing rules to keep in mind:
129+
130+
- All parentheses must be matched
131+
- All measurement operators must have a leading and following space. i.e. ` * `
132+
- Only integer exponentiation is supported
133+
- Exponentiated measurements must have parentheses to avoid ambiguity with units. i.e. `(3m)^2`
121134

122135
## Default Units
123136

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

238251
```bash
239-
unit convert 5_m/s mi/hr # Returns 11.184681460272012 mi/hr
252+
unit convert 5m/s mi/hr # Returns 11.184681460272012 mi/hr
240253
```
241254

242-
This command uses the unit and measurement [serialization format](#serialization). Note that for
255+
This command uses the unit and expression [serialization format](#serialization). Note that for
243256
convenience, you may use an underscore `_` to represent the normally serialized space. Also,
244257
`*` characters may need to be escaped.
245258

259+
You can also evaulate math in the first argument. For example:
260+
261+
```bash
262+
unit convert "60mi/hr * 30min" "mi" # Returns 30.0 mi
263+
```
264+
246265
### List
247266

248267
To list the available units, use the `unit list` command:

Sources/CLI/Convert.swift

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Units
33

44
struct Convert: ParsableCommand {
55
static var configuration = CommandConfiguration(
6-
abstract: "Convert a measurement to a specified unit.",
6+
abstract: "Convert a measurement expression to a specified unit.",
77
discussion: """
88
Run `unit list` to see the supported unit symbols and names. Unless arguments are wrapped \
99
in quotes, the `*` character may need to be escaped.
@@ -12,20 +12,21 @@ struct Convert: ParsableCommand {
1212
https://github.com/NeedleInAJayStack/Units/blob/main/README.md#serialization
1313
1414
EXAMPLES:
15-
unit convert 1_ft m
15+
unit convert 1ft m
1616
unit convert 1_ft meter
1717
unit convert 5.4_kW\\*hr J
1818
unit convert 5.4e-3_km/s mi/hr
1919
unit convert "12 kg*m/s^2" "N"
20-
unit convert 12_m^1\\*s^-1\\*kg^1 kg\\*m/s
20+
unit convert "8kg * 3m / 2s^2" "N"
2121
"""
2222
)
2323

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

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

3637
func run() throws {
37-
try print(from.convert(to: to))
38+
try print(from.solve().convert(to: to))
3839
}
3940
}
4041

41-
extension Measurement: ExpressibleByArgument {
42-
public init?(argument: String) {
42+
extension Expression: ExpressibleByArgument {
43+
public convenience init?(argument: String) {
4344
let argument = argument.replacingOccurrences(of: "_", with: " ")
44-
guard let measurement = Measurement(argument) else {
45-
return nil
46-
}
47-
self = measurement
45+
try? self.init(argument)
4846
}
4947
}
5048

Sources/Units/Expression.swift

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/// Represents a mathematical expression of measurements. It supports arithemetic operators, exponents, and sub-expressions.
2+
public final class Expression {
3+
// Implemented as a linked list of ExpressionNodes. This allows us to indicate operators,
4+
// and iteratively solve by reducing the list according to the order of operations.
5+
6+
var first: ExpressionNode
7+
var last: ExpressionNode
8+
var count: Int
9+
10+
init(node: ExpressionNode) {
11+
self.first = node
12+
self.last = node
13+
count = 1
14+
}
15+
16+
/// Initializes an expression from a string.
17+
///
18+
/// Parsing rules:
19+
/// - All parentheses must be matched
20+
/// - All measurement operators must have a leading and following space. i.e. ` * `
21+
/// - Only integer exponentiation is supported
22+
/// - Exponentiated measurements must have parentheses to avoid ambiguity with units. i.e. `(3m)^2`
23+
///
24+
/// Examples:
25+
/// - `5m + 3m`
26+
/// - `5.3 m + 3.8 m`
27+
/// - `5m^2/s + (1m + 2m)^2 / 5s`
28+
///
29+
/// - Parameter expr: The string expression to parse.
30+
public init(_ expr: String) throws {
31+
let parsed = try Parser(expr).parseExpression()
32+
self.first = parsed.first
33+
self.last = parsed.last
34+
self.count = parsed.count
35+
}
36+
37+
/// Reduces the expression to a single measurement, respecting the [order of operations](https://en.wikipedia.org/wiki/Order_of_operations)
38+
public func solve() throws -> Measurement {
39+
let copy = self.copy()
40+
return try copy.computeAndDestroy()
41+
}
42+
43+
@discardableResult
44+
func append(op: Operator, node: ExpressionNode) -> Self {
45+
last.next = .init(op: op, node: node)
46+
last = node
47+
count = count + 1
48+
return self
49+
}
50+
51+
func copy() -> Expression {
52+
// Copy the expression list so the original is not destroyed
53+
let copy = Expression(node: first.copy())
54+
var traversal = first
55+
while let next = traversal.next {
56+
copy.append(op: next.op, node: next.node.copy())
57+
traversal = next.node
58+
}
59+
return copy
60+
}
61+
62+
/// Reduces the expression to a single measurement, respecting the [order of operations](https://en.wikipedia.org/wiki/Order_of_operations)
63+
///
64+
/// NOTE: This flattens the list, destroying it. Use `solve` for non-destructive behavior.
65+
private func computeAndDestroy() throws -> Measurement {
66+
67+
// SubExpressions
68+
func computeSubExpression(node: ExpressionNode) throws {
69+
switch node.value {
70+
case .measurement:
71+
return // Just pass through
72+
case let .subExpression(expression):
73+
// Reassign node's value from subExpression to the solved value
74+
try node.value = .measurement(expression.solve())
75+
}
76+
}
77+
var left = first
78+
while let next = left.next {
79+
try computeSubExpression(node: left)
80+
left = next.node
81+
}
82+
try computeSubExpression(node: left)
83+
// At this point, there should be no more sub expressions
84+
85+
// Exponentals
86+
func exponentiate(node: ExpressionNode) throws {
87+
guard let exponent = node.exponent else {
88+
return
89+
}
90+
switch node.value {
91+
case .subExpression:
92+
fatalError("Parentheses still present during exponent phase")
93+
case let .measurement(measurement):
94+
// Reassign node's value to the exponentiated result & clear exponent
95+
node.value = .measurement(measurement.pow(exponent))
96+
node.exponent = nil
97+
}
98+
}
99+
left = first
100+
while let next = left.next {
101+
try exponentiate(node: left)
102+
left = next.node
103+
}
104+
try exponentiate(node: left)
105+
106+
// Multiplication
107+
left = first
108+
while let next = left.next {
109+
let right = next.node
110+
switch (left.value, right.value) {
111+
case let (.measurement(leftMeasurement), .measurement(rightMeasurement)):
112+
switch next.op {
113+
case .add, .subtract: // Skip over operation
114+
left = right
115+
case .multiply: // Compute and absorb right node into left
116+
left.value = .measurement(leftMeasurement * rightMeasurement)
117+
left.next = right.next
118+
case .divide: // Compute and absorb right node into left
119+
left.value = .measurement(leftMeasurement / rightMeasurement)
120+
left.next = right.next
121+
}
122+
default:
123+
fatalError("Parentheses still present during multiplication phase")
124+
}
125+
}
126+
127+
// Addition
128+
left = first
129+
while let next = left.next {
130+
let right = next.node
131+
switch (left.value, right.value) {
132+
case let (.measurement(leftMeasurement), .measurement(rightMeasurement)):
133+
switch next.op {
134+
case .add: // Compute and absorb right node into left
135+
left.value = try .measurement(leftMeasurement + rightMeasurement)
136+
left.next = right.next
137+
case .subtract: // Compute and absorb right node into left
138+
left.value = try .measurement(leftMeasurement - rightMeasurement)
139+
left.next = right.next
140+
case .multiply, .divide:
141+
fatalError("Multiplication still present during addition phase")
142+
}
143+
default:
144+
fatalError("Parentheses still present during addition phase")
145+
}
146+
}
147+
148+
if first.next != nil {
149+
fatalError("Expression list reduction not complete")
150+
}
151+
switch first.value {
152+
case let .measurement(measurement):
153+
return measurement
154+
default:
155+
fatalError("Final value is not a computed measurement")
156+
}
157+
}
158+
}
159+
160+
extension Expression: CustomStringConvertible {
161+
public var description: String {
162+
var result = first.value.description
163+
var traversal = first
164+
while let next = traversal.next {
165+
result = result + " \(next.op.rawValue) \(next.node.value.description)"
166+
traversal = next.node
167+
}
168+
return result
169+
}
170+
}
171+
172+
extension Expression: Equatable {
173+
public static func == (lhs: Expression, rhs: Expression) -> Bool {
174+
guard lhs.count == rhs.count else {
175+
return false
176+
}
177+
var lhsNode = lhs.first
178+
var rhsNode = rhs.first
179+
guard lhsNode == rhsNode else {
180+
return false
181+
}
182+
while let lhsNext = lhsNode.next, let rhsNext = rhsNode.next {
183+
guard lhsNext == rhsNext else {
184+
return false
185+
}
186+
lhsNode = lhsNext.node
187+
rhsNode = rhsNext.node
188+
}
189+
return true
190+
}
191+
}
192+
193+
class ExpressionNode {
194+
var value: ExpressionNodeValue
195+
var exponent: Int?
196+
var next: ExpressionLink?
197+
198+
init(_ value: ExpressionNodeValue, exponent: Int? = nil, next: ExpressionLink? = nil) {
199+
self.value = value
200+
self.exponent = exponent
201+
self.next = next
202+
}
203+
204+
func copy() -> ExpressionNode {
205+
return .init(value.copy(), exponent: self.exponent)
206+
}
207+
}
208+
209+
extension ExpressionNode: Equatable {
210+
static func == (lhs: ExpressionNode, rhs: ExpressionNode) -> Bool {
211+
return lhs.value == rhs.value &&
212+
lhs.exponent == rhs.exponent
213+
}
214+
}
215+
216+
enum ExpressionNodeValue {
217+
case measurement(Measurement)
218+
case subExpression(Expression)
219+
220+
func copy() -> ExpressionNodeValue {
221+
switch self {
222+
case let .measurement(measurement):
223+
return .measurement(measurement)
224+
case let .subExpression(expression):
225+
return .subExpression(expression.copy())
226+
}
227+
}
228+
}
229+
230+
extension ExpressionNodeValue: CustomStringConvertible {
231+
var description: String {
232+
switch self {
233+
case let .measurement(measurement):
234+
return measurement.description
235+
case let .subExpression(subExpression):
236+
return "(\(subExpression.description))"
237+
}
238+
}
239+
}
240+
241+
extension ExpressionNodeValue: Equatable {
242+
static func == (lhs: ExpressionNodeValue, rhs: ExpressionNodeValue) -> Bool {
243+
switch (lhs, rhs) {
244+
case let (.measurement(lhsM), .measurement(rhsM)):
245+
return lhsM == rhsM
246+
case let (.subExpression(lhsE), .subExpression(rhsE)):
247+
return lhsE == rhsE
248+
default:
249+
return false
250+
}
251+
}
252+
}
253+
254+
class ExpressionLink {
255+
let op: Operator
256+
let node: ExpressionNode
257+
258+
init(op: Operator, node: ExpressionNode) {
259+
self.op = op
260+
self.node = node
261+
}
262+
}
263+
264+
extension ExpressionLink: Equatable {
265+
static func == (lhs: ExpressionLink, rhs: ExpressionLink) -> Bool {
266+
return lhs.op == rhs.op &&
267+
lhs.node == rhs.node
268+
}
269+
}
270+
271+
enum Operator: String {
272+
case add = "+"
273+
case subtract = "-"
274+
case multiply = "*"
275+
case divide = "/"
276+
}

0 commit comments

Comments
 (0)