diff --git a/README.md b/README.md index f576f92..8c2d106 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/Sources/CLI/Convert.swift b/Sources/CLI/Convert.swift index 6130e34..8a9f25f 100644 --- a/Sources/CLI/Convert.swift +++ b/Sources/CLI/Convert.swift @@ -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. @@ -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 \ @@ -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) } } diff --git a/Sources/Units/Expression.swift b/Sources/Units/Expression.swift new file mode 100644 index 0000000..a72f911 --- /dev/null +++ b/Sources/Units/Expression.swift @@ -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 = "/" +} diff --git a/Sources/Units/Measurement/Measurement.swift b/Sources/Units/Measurement/Measurement.swift index 8589f43..3e37213 100644 --- a/Sources/Units/Measurement/Measurement.swift +++ b/Sources/Units/Measurement/Measurement.swift @@ -163,22 +163,11 @@ extension Measurement: CustomStringConvertible { extension Measurement: LosslessStringConvertible { public init?(_ description: String) { - let valueEndIndex = description.firstIndex(of: " ") ?? description.endIndex - guard let value = Double(description[.. Measurement { + let value: Double + switch try next() { + case let .number(parsed): + value = parsed + default: + throw ParserError.invalidMeasurement + } + + let unit: Unit + switch try next() { + case let .unit(parsed): + unit = parsed + case .eof: + unit = .none + default: + throw ParserError.invalidMeasurement + } + + return Measurement(value: value, unit: unit) + } + + func parseExpression() throws -> Expression { + return try parseExpression(isSubExpression: false) + } + + private func parseExpression(isSubExpression: Bool) throws -> Expression { + var expression: Expression? = nil + + var token = try next() + var op: Operator? = nil + // We do while/true because we can exit on either eof or rParen, depending on isSubExpression + parseLoop: while true { + switch token { + case .eof: + guard !isSubExpression else { + throw ParserError.invalidExpression(reason: "No right parentheses following left parentheses") + } + break parseLoop + case let .number(value): + let unit: Unit + + // Check next token to see if it is a unit. Continue loop to avoid calling next again below. + let nextToken = try next() + switch nextToken { + case let .unit(parsed): + unit = parsed + token = try next() + default: + unit = .none + token = nextToken + } + let measurement = Measurement(value: value, unit: unit) + let node = ExpressionNode(.measurement(measurement)) + if let expression = expression { + guard let op = op else { + throw ParserError.invalidExpression(reason: "No operator preceeding measurement \(measurement)") + } + expression.append(op: op, node: node) + } else { + expression = Expression(node: node) + } + op = nil + continue parseLoop + case .lParen: + let subExpression = try parseExpression(isSubExpression: true) + let node = ExpressionNode(.subExpression(subExpression)) + + if let expression = expression { + guard let op = op else { + throw ParserError.invalidExpression(reason: "No operator preceeding left parentheses") + } + expression.append(op: op, node: node) + } else { + expression = Expression(node: node) + } + op = nil + case .rParen: + guard isSubExpression else { + throw ParserError.invalidExpression(reason: "No left parentheses preceeding right parentheses") + } + guard expression != nil else { + throw ParserError.invalidExpression(reason: "No expression preceeding right parentheses") + } + break parseLoop + case let .exp(exponent): + guard let expression = expression else { + throw ParserError.invalidExpression(reason: "No node preceeding exponent") + } + expression.last.exponent = exponent + op = nil + case .add: + op = .add + case .sub: + op = .subtract + case .mult: + op = .multiply + case .div: + op = .divide + case let .unit(unit): + // Measurement unit parsing handled above + throw ParserError.invalidExpression(reason: "Unexpected unit: \(unit)") + } + token = try next() + } + + if let op = op { + throw ParserError.invalidExpression(reason: "Expression ended with operator `\(op)`") + } + guard let expression = expression else { + throw ParserError.invalidExpression(reason: "Expression contained no complete node") + } + return expression + } + + private func next() throws -> Token { + guard let char = cur else { + return .eof + } + + if char.isNumber { + let startPosition = position + var numberString = "" + while let cur = cur, (cur.isNumber || cur == ".") { + numberString.append(cur) + consume() + } + guard let number = Double(numberString) else { + throw ParserError.unableToParseNumber(numberString, position: startPosition) + } + return .number(number) + } + else if char == "(" { + consume() + return .lParen + } + else if char == ")" { + consume() + return .rParen + } + else if char == "^" { + let startPosition = position + try consume("^") + var intString = "" + while let cur = cur, cur.isNumber { + intString.append(cur) + consume() + } + guard let int = Int(intString) else { + throw ParserError.unableToParseExponent("^\(intString)", position: startPosition) + } + return .exp(int) + } + else if char.isWhitespace { + if peek == "+" { + try consume(" ") + try consume("+") + try consume(" ") + return .add + } + if peek == "-" { + try consume(" ") + try consume("-") + try consume(" ") + return .sub + } + if peek == "*" { + try consume(" ") + try consume("*") + try consume(" ") + return .mult + } + if peek == "/" { + try consume(" ") + try consume("/") + try consume(" ") + return .div + } + + // consume and try again + consume() + return try next() + } + else { + var unitString = "" + while let cur = cur, cur != "(" && cur != ")" && !cur.isWhitespace { + unitString.append(cur) + consume() + } + let unit = try Unit.init(fromSymbol: unitString) + return .unit(unit) + } + } + + private func consume() { + position = position + 1 + } + + private func consume(_ expected: Character) throws { + guard let character = cur else { + return + } + if character != expected { + throw ParserError.unexpectedCharacter(character, position: position) + } + position = position + 1 + } +} + +enum Token: Equatable { + case number(Double) + case unit(Unit) + case lParen + case rParen + case add + case sub + case mult + case div + case exp(Int) + case eof +} + +enum ParserError: Error { + case unexpectedCharacter(Character, position: Int) + case unableToParseNumber(String, position: Int) + case unableToParseExponent(String, position: Int) + + case invalidMeasurement + case invalidExpression(reason: String) +} diff --git a/Tests/UnitsTests/ExpressionTests.swift b/Tests/UnitsTests/ExpressionTests.swift new file mode 100644 index 0000000..08ef8e8 --- /dev/null +++ b/Tests/UnitsTests/ExpressionTests.swift @@ -0,0 +1,213 @@ +@testable import Units +import XCTest + +final class ExpressionTests: XCTestCase { + func testParse() throws { + XCTAssertEqual( + try Expression("5m + 3m"), + Expression(node: .init(.measurement(5.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) + ) + + XCTAssertEqual( + try Expression("5.3 m + 3.8 m"), + Expression(node: .init(.measurement(5.3.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(3.8.measured(in: .meter)))) + ) + + XCTAssertEqual( + try Expression("5m^2/s + (1m + 2m)^2 / 5s"), + Expression(node: .init(.measurement(5.measured(in: .meter * .meter / .second)))) + .append(op: .add, node: .init( + .subExpression( + .init(node: .init(.measurement(1.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) + ), + exponent: 2 + )) + .append(op: .divide, node: .init(.measurement(5.measured(in: .second)))) + ) + } + + func testSingleMeasurement() throws { + try XCTAssertEqual( + Expression("5kW").solve(), + 5.measured(in: .kilowatt) + ) + } + + func testAddition() throws { + try XCTAssertEqual( + Expression("5kW + 2kW").solve(), + 7.measured(in: .kilowatt) + ) + } + + func testSubtraction() throws { + try XCTAssertEqual( + Expression("5kW - 2kW").solve(), + 3.measured(in: .kilowatt) + ) + } + + func testMultiplication() throws { + try XCTAssertEqual( + Expression("5kW * 2hr").solve(), + 10.measured(in: .kilowatt * .hour) + ) + } + + func testDivision() throws { + try XCTAssertEqual( + Expression("6m / 2s").solve(), + 3.measured(in: .meter / .second) + ) + } + + func testExponent() throws { + try XCTAssertEqual( + Expression("(3m)^2").solve(), + 9.measured(in: .meter * .meter) + ) + + try XCTAssertEqual( + Expression("(3m + 2m)^2").solve(), + 25.measured(in: .meter * .meter) + ) + + try XCTAssertEqual( + Expression("3m^2 + (2m)^2").solve(), + 7.measured(in: .meter * .meter) + ) + } + + func testParentheses() throws { + try XCTAssertEqual( + Expression("(5kW) * 2hr").solve(), + 10.measured(in: .kilowatt * .hour) + ) + + try XCTAssertEqual( + Expression("5kW * (2hr)").solve(), + 10.measured(in: .kilowatt * .hour) + ) + + try XCTAssertEqual( + Expression("5kW * (2hr + 1hr)").solve(), + 15.measured(in: .kilowatt * .hour) + ) + } + + func testOrderOfOperations() throws { + try XCTAssertEqual( + Expression("5kW * 2hr + 3kW*hr").solve(), + 13.measured(in: .kilowatt * .hour) + ) + + try XCTAssertEqual( + Expression("5kW*hr + 3kW * 2hr").solve(), + 11.measured(in: .kilowatt * .hour) + ) + + try XCTAssertEqual( + Expression("5kW*hr + 3kW * 2hr + 2kW*hr").solve(), + 13.measured(in: .kilowatt * .hour) + ) + + try XCTAssertEqual( + Expression("5kW * 3hr + 2kW * 2hr").solve(), + 19.measured(in: .kilowatt * .hour) + ) + + try XCTAssertEqual( + Expression("5kW * (3hr + 2hr) * 2hr").solve(), + 50.measured(in: .kilowatt * .hour * .hour) + ) + } + + func testDescription() throws { + try XCTAssertEqual( + Expression("5kW * 3hr + 2kW * 2hr").description, + "5.0 kW * 3.0 hr + 2.0 kW * 2.0 hr" + ) + } + + func testPrintParseCycle() throws { + try XCTAssertEqual( + Expression(Expression("5kW * 3hr + 2kW * 2hr").description), + Expression("5kW * 3hr + 2kW * 2hr") + ) + + try XCTAssertEqual( + Expression(Expression("5kW * (3hr + 2hr) * 2hr").description), + Expression("5kW * (3hr + 2hr) * 2hr") + ) + } + + func testEquatable() throws { + try XCTAssertEqual( + Expression("5kW + 3kW + 2kW"), + Expression("5kW + 3kW + 2kW") + ) + } + + func testNotEqualWhenCountsDontMatch() throws { + try XCTAssertNotEqual( + Expression("5kW + 3kW"), + Expression("5kW + 3kW + 2kW") + ) + } + + func testNotEqualWhenOperatorsDontMatch() throws { + try XCTAssertNotEqual( + Expression("5kW + 3kW"), + Expression("5kW * 3kW") + ) + } + + func testNotEqualWhenUnitsDontMatch() throws { + try XCTAssertNotEqual( + Expression("5kW + 3kW"), + Expression("5W + 3W") + ) + } + + func testNotEqualWhenScalarsDontMatch() throws { + try XCTAssertNotEqual( + Expression("5kW + 3kW"), + Expression("5kW + 4kW") + ) + } + + func testNotEqualWhenExponentsDontMatch() throws { + try XCTAssertNotEqual( + Expression("(5kW)^2"), + Expression("(5kW)^3") + ) + } + + func testNotEqualWhenSubExpressionsDontMatch() throws { + try XCTAssertNotEqual( + Expression("5kW * (2hr + 1hr)"), + Expression("5kW * (1hr + 1hr)") + ) + } + + func testSolveIsNotDestructive() throws { + let expression = try Expression("5kW + 2kW") + + XCTAssertEqual( + expression.description, + "5.0 kW + 2.0 kW" + ) + + try XCTAssertNoThrow(expression.solve()) + + expression.append(op: .add, node: .init(.measurement(3.measured(in: .kilowatt)))) + + XCTAssertEqual( + expression.description, + "5.0 kW + 2.0 kW + 3.0 kW" + ) + } +} diff --git a/Tests/UnitsTests/ParserTests.swift b/Tests/UnitsTests/ParserTests.swift new file mode 100644 index 0000000..356ac1e --- /dev/null +++ b/Tests/UnitsTests/ParserTests.swift @@ -0,0 +1,177 @@ +@testable import Units +import XCTest + +final class ParseMeasurementTests: XCTestCase { + func testNoUnit() throws { + XCTAssertEqual( + try Parser("5.1").parseMeasurement(), + 5.1.measured(in: .none) + ) + } + + func testSimpleUnit() throws { + XCTAssertEqual( + try Parser("5.1 kW").parseMeasurement(), + 5.1.measured(in: .kilowatt) + ) + } + + func testUnitWithSymbol() throws { + XCTAssertEqual( + try Parser("5.1 °F").parseMeasurement(), + 5.1.measured(in: .fahrenheit) + ) + } + + func testComplexUnit() throws { + XCTAssertEqual( + try Parser("5.1 m^2*kg/s^3").parseMeasurement(), + 5.1.measured(in: .meter * .meter * .kilogram / .second / .second / .second) + ) + } + + func testHandlesWhitespace() throws { + XCTAssertEqual( + try Parser(" 5.1 ").parseMeasurement(), + 5.1.measured(in: .none) + ) + + XCTAssertEqual( + try Parser("5.1 kW").parseMeasurement(), + 5.1.measured(in: .kilowatt) + ) + + XCTAssertEqual( + try Parser("5.1kW").parseMeasurement(), + 5.1.measured(in: .kilowatt) + ) + } + + func testHandlesNoDecimal() throws { + XCTAssertEqual( + try Parser("5 kW").parseMeasurement(), + 5.measured(in: .kilowatt) + ) + } + + func testFailsOnBadUnit() throws { + XCTAssertThrowsError( + try Parser("5 +").parseMeasurement() + ) + } + + func testFailsOnUnknownUnit() throws { + XCTAssertThrowsError( + try Parser("5 flippers").parseMeasurement() + ) + } + + func testFailsOnBadValue() throws { + XCTAssertThrowsError( + try Parser("orange kW").parseMeasurement() + ) + } +} + +final class ParseExpressionTests: XCTestCase { + func testSimple() throws { + XCTAssertEqual( + try Parser("5 m + 3 m").parseExpression(), + Expression(node: .init(.measurement(5.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) + ) + } + + func testComplex() throws { + XCTAssertEqual( + try Parser("5 m^2/s + (1 m + 2 m)^2 / 5 s").parseExpression(), + Expression(node: .init(.measurement(5.measured(in: .meter * .meter / .second)))) + .append(op: .add, node: .init( + .subExpression( + .init(node: .init(.measurement(1.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) + ), + exponent: 2 + )) + .append(op: .divide, node: .init(.measurement(5.measured(in: .second)))) + ) + } + + func testNestedExpressions() throws { + XCTAssertEqual( + try Parser("5 m * (1 m * (1 m + 2 m))").parseExpression(), + Expression(node: .init(.measurement(5.measured(in: .meter)))) + .append(op: .multiply, node: .init( + .subExpression( + .init(node: .init(.measurement(1.measured(in: .meter)))) + .append(op: .multiply, node: .init( + .subExpression( + .init(node: .init(.measurement(1.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) + ) + )) + ) + )) + ) + } + + func testNoUnit() throws { + XCTAssertEqual( + try Parser("5 + 2 * 3").parseExpression(), + Expression(node: .init(.measurement(5.measured(in: .none)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .none)))) + .append(op: .multiply, node: .init(.measurement(3.measured(in: .none)))) + ) + } + + func testHandlesWhitespace() throws { + XCTAssertEqual( + try Parser("5 m + 3 m").parseExpression(), + Expression(node: .init(.measurement(5.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) + ) + + XCTAssertEqual( + try Parser("5m + 3m").parseExpression(), + Expression(node: .init(.measurement(5.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) + ) + } + + func testFailsOnUnspacedOperators() throws { + XCTAssertThrowsError( + try Parser("5m+3m").parseExpression() + ) + XCTAssertThrowsError( + try Parser("5m-3m").parseExpression() + ) + XCTAssertThrowsError( + try Parser("5m*3m").parseExpression() + ) + XCTAssertThrowsError( + try Parser("5m/3m").parseExpression() + ) + } + + func testFailsOnIncompleteExpression() throws { + XCTAssertThrowsError( + try Parser("5m + ").parseExpression() + ) + + XCTAssertThrowsError( + try Parser("(5m + 2m) - (3m").parseExpression() + ) + + XCTAssertThrowsError( + try Parser("(5m)^").parseExpression() + ) + + XCTAssertThrowsError( + try Parser("(5m + 2m) (3m)").parseExpression() + ) + + XCTAssertThrowsError( + try Parser(") + (5m + 2m)").parseExpression() + ) + } +}