Files
conjure/Sources/ConjureKit/Calculator.swift
T

375 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
public struct CalculatorResult: Sendable {
public let displayValue: String
public let copyValue: String?
public let detail: String
}
public enum Calculator {
private static let outputLocale = Locale(identifier: "en_US_POSIX")
private static let functionNames: Set<String> = [
"sqrt", "abs", "round", "floor", "ceil", "sin", "cos", "tan", "log", "ln",
]
// ponytail: cap recursion for small debug worker stacks; use an iterative parser if deeper input is needed.
private static let maximumDepth = 16
public static func evaluate(_ query: String) -> CalculatorResult? {
guard query.utf8.count <= 512 else { return nil }
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return nil }
let words = query.split(whereSeparator: \.isWhitespace).map(String.init)
if words.count >= 3 {
for separator in 1..<(words.count - 1)
where ["to", "in", "as"].contains(words[separator].lowercased()) {
guard let target = ConversionUnit.parse(words[(separator + 1)...].joined(separator: " ")) else {
continue
}
let sourceText = words[..<separator].joined(separator: " ")
// A suffix boundary also accepts Raycast-style quantities such as 10ft and 23°C.
let characters = Array(sourceText)
guard characters.count > 1 else { continue }
for length in stride(from: min(32, characters.count - 1), through: 1, by: -1) {
guard let source = ConversionUnit.parse(String(characters.suffix(length))) else { continue }
let expression = String(characters.dropLast(length))
guard let parsed = parse(expression) else { continue }
let detail = "\(expression.trimmingCharacters(in: .whitespaces)) \(source.symbol) → \(target.symbol)"
if let error = parsed.error { return failure(error, detail: detail) }
guard source.family == target.family else {
return failure("Incompatible units", detail: detail)
}
let value: Double
if let sourceDimension = source.dimension, let targetDimension = target.dimension {
value = Measurement(value: parsed.value, unit: sourceDimension)
.converted(to: targetDimension).value
} else {
value = parsed.value * source.scale / target.scale
}
guard value.isFinite else { return failure("Result is not finite", detail: detail) }
let text = "\(format(value)) \(target.symbol)"
return CalculatorResult(displayValue: text, copyValue: text, detail: detail)
}
}
}
guard let parsed = parse(query), parsed.isExpression else { return nil }
if let error = parsed.error { return failure(error, detail: query) }
let text = format(parsed.value)
return CalculatorResult(displayValue: text, copyValue: text, detail: query)
}
private static func failure(_ error: String, detail: String) -> CalculatorResult {
CalculatorResult(displayValue: error, copyValue: nil, detail: detail)
}
private static func format(_ value: Double) -> String {
String(format: "%.12g", locale: outputLocale, value == 0 ? 0 : value)
}
private static func parse(_ expression: String) -> Parser? {
guard let tokens = tokenize(expression), !tokens.isEmpty else { return nil }
var parser = Parser(tokens: tokens)
do {
parser.value = try parser.sum(depth: 0)
guard parser.index == tokens.count else { return nil }
return parser
} catch { return nil }
}
private enum Token: Equatable {
case number(Double), name(String)
case plus, minus, multiply, divide, power, percent, modulo, of, off, left, right
}
private static func tokenize(_ expression: String) -> [Token]? {
let characters = Array(expression)
var tokens: [Token] = []
var index = 0
func digit(_ character: Character) -> Bool { character >= "0" && character <= "9" }
while index < characters.count {
let character = characters[index]
if character.isWhitespace { index += 1; continue }
if digit(character) || character == "." {
let start = index
var digits = 0
while index < characters.count, digit(characters[index]) { index += 1; digits += 1 }
if index < characters.count, characters[index] == "." {
index += 1
while index < characters.count, digit(characters[index]) { index += 1; digits += 1 }
}
guard digits > 0 else { return nil }
if index < characters.count, characters[index] == "e" || characters[index] == "E" {
index += 1
if index < characters.count, characters[index] == "+" || characters[index] == "-" { index += 1 }
let exponentStart = index
while index < characters.count, digit(characters[index]) { index += 1 }
guard index > exponentStart else { return nil }
}
guard let number = Double(String(characters[start..<index])) else { return nil }
tokens.append(.number(number))
continue
}
switch character {
case "+": tokens.append(.plus)
case "-", "−": tokens.append(.minus)
case "*", "×": tokens.append(.multiply)
case "/", "÷": tokens.append(.divide)
case "^": tokens.append(.power)
case "%": tokens.append(.percent)
case "(": tokens.append(.left)
case ")": tokens.append(.right)
case "π": tokens.append(.name("pi"))
default:
guard character.isASCII, character.isLetter else { return nil }
let start = index
while index < characters.count, characters[index].isASCII, characters[index].isLetter { index += 1 }
let name = String(characters[start..<index]).lowercased()
if name == "x" { tokens.append(.multiply) }
else if name == "mod" || name == "modulo" { tokens.append(.modulo) }
else if name == "of" { tokens.append(.of) }
else if name == "off" { tokens.append(.off) }
else { tokens.append(.name(name)) }
continue
}
index += 1
}
return tokens
}
private enum ParseError: Error { case invalid }
private struct Parser {
let tokens: [Token]
var index = 0
var value = 0.0
var error: String?
var isExpression = false
var current: Token? { index < tokens.count ? tokens[index] : nil }
mutating func checked(_ value: Double, message: String = "Result is not finite") -> Double {
guard value.isFinite else {
if error == nil { error = message }
return 0
}
return value
}
mutating func sum(depth: Int) throws -> Double {
var value = try product(depth: depth)
while current == .plus || current == .minus {
let subtract = current == .minus
index += 1
isExpression = true
let rhs = try product(depth: depth)
value = checked(subtract ? value - rhs : value + rhs)
}
return value
}
mutating func product(depth: Int) throws -> Double {
var value = try unary(depth: depth)
while current == .multiply || current == .divide || current == .modulo || current == .percent
|| current == .of || current == .off {
let operation = current
if operation == .of || operation == .off {
guard index > 0, tokens[index - 1] == .percent else { throw ParseError.invalid }
}
index += 1
isExpression = true
let rhs = operation == .off ? try sum(depth: depth + 1) : try unary(depth: depth)
switch operation {
case .multiply, .of: value = checked(value * rhs)
case .off: value = checked(rhs * (1 - value))
case .divide:
if rhs == 0 { if error == nil { error = "Division by zero" }; value = 0 }
else { value = checked(value / rhs) }
default:
if rhs == 0 { if error == nil { error = "Division by zero" }; value = 0 }
else { value = checked(value.truncatingRemainder(dividingBy: rhs)) }
}
}
return value
}
mutating func unary(depth: Int) throws -> Double {
guard depth < Calculator.maximumDepth else { throw ParseError.invalid }
if current == .plus || current == .minus {
let negate = current == .minus
index += 1
let value = try unary(depth: depth + 1)
return negate ? -value : value
}
return try power(depth: depth)
}
mutating func power(depth: Int) throws -> Double {
var value = try primary(depth: depth)
while current == .percent {
// An unsigned operand after % means remainder; otherwise % is postfix percentage.
if index + 1 < tokens.count {
switch tokens[index + 1] {
case .number, .name, .left: return try exponent(value, depth: depth)
default: break
}
}
index += 1
isExpression = true
value = checked(value / 100)
}
return try exponent(value, depth: depth)
}
mutating func exponent(_ value: Double, depth: Int) throws -> Double {
guard current == .power else { return value }
index += 1
isExpression = true
let rhs = try unary(depth: depth + 1)
return checked(pow(value, rhs), message: "Power is outside its domain")
}
mutating func primary(depth: Int) throws -> Double {
guard let token = current else { throw ParseError.invalid }
index += 1
switch token {
case .number(let value): return checked(value)
case .left:
guard depth < Calculator.maximumDepth else { throw ParseError.invalid }
isExpression = true
let value = try sum(depth: depth + 1)
guard current == .right else { throw ParseError.invalid }
index += 1
return value
case .name(let name):
isExpression = true
if name == "pi" { return .pi }
if name == "e" { return exp(1) }
guard Calculator.functionNames.contains(name),
current == .left, depth < Calculator.maximumDepth else { throw ParseError.invalid }
index += 1
let argument = try sum(depth: depth + 1)
guard current == .right else { throw ParseError.invalid }
index += 1
let value: Double
switch name {
case "sqrt": value = sqrt(argument)
case "abs": value = abs(argument)
case "round": value = argument.rounded()
case "floor": value = floor(argument)
case "ceil": value = ceil(argument)
case "sin": value = sin(argument)
case "cos": value = cos(argument)
case "tan": value = tan(argument)
case "log": value = log10(argument)
default: value = log(argument)
}
return checked(value, message: "\(name) is outside its domain")
default: throw ParseError.invalid
}
}
}
private struct ConversionUnit {
let family: String
let symbol: String
let dimension: Dimension?
let scale: Double
init(_ family: String, _ symbol: String, _ dimension: Dimension) {
self.family = family
self.symbol = symbol
self.dimension = dimension
self.scale = 1
}
init(_ family: String, _ symbol: String, scale: Double) {
self.family = family
self.symbol = symbol
self.dimension = nil
self.scale = scale
}
private static let dataPrefixes: [String: Double] = [
"": 1, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12,
"Ki": 1024, "Mi": 1_048_576, "Gi": 1_073_741_824, "Ti": 1_099_511_627_776,
]
private static let namedDataPrefixes: [String: (String, Double)] = [
"": ("", 1), "kilo": ("K", 1e3), "mega": ("M", 1e6), "giga": ("G", 1e9), "tera": ("T", 1e12),
"kibi": ("Ki", 1024), "mebi": ("Mi", 1_048_576),
"gibi": ("Gi", 1_073_741_824), "tebi": ("Ti", 1_099_511_627_776),
]
private static let gallonLiters = UnitVolume.gallons.converter.baseUnitValue(fromValue: 1)
// Native Fahrenheit coefficients leave 32 °F slightly above zero; preserve the defining ratio.
private static let fahrenheit = UnitTemperature(
symbol: "°F",
converter: UnitConverterLinear(coefficient: 5.0 / 9.0, constant: 273.15 - 32.0 * 5.0 / 9.0)
)
private static func usVolume(_ symbol: String, subdivisions: Double) -> ConversionUnit {
// Foundation's cup is a 240 mL legal cup; these are consistent US customary gallon subdivisions.
.init("volume", symbol, UnitVolume(
symbol: symbol, converter: UnitConverterLinear(coefficient: gallonLiters / subdivisions)
))
}
static func parse(_ text: String) -> ConversionUnit? {
let text = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
// Keep the final b/B case-sensitive: bits and bytes differ by a factor of eight.
if let suffix = text.last, suffix == "b" || suffix == "B" {
let prefix = String(text.dropLast())
if let scale = dataPrefixes[prefix] {
return ConversionUnit("data", text, scale: suffix == "b" ? scale / 8 : scale)
}
}
let lowercase = text.lowercased()
for suffix in ["bytes", "byte", "bits", "bit"] where lowercase.hasSuffix(suffix) {
if let (prefix, scale) = namedDataPrefixes[String(lowercase.dropLast(suffix.count))] {
let bytes = suffix.hasPrefix("byte")
return .init("data", prefix + (bytes ? "B" : "b"), scale: bytes ? scale : scale / 8)
}
}
switch lowercase {
case "mm", "millimeter", "millimeters", "millimetre", "millimetres": return .init("length", "mm", UnitLength.millimeters)
case "cm", "centimeter", "centimeters", "centimetre", "centimetres": return .init("length", "cm", UnitLength.centimeters)
case "m", "meter", "meters", "metre", "metres": return .init("length", "m", UnitLength.meters)
case "km", "kilometer", "kilometers", "kilometre", "kilometres": return .init("length", "km", UnitLength.kilometers)
case "in", "inch", "inches": return .init("length", "in", UnitLength.inches)
case "ft", "foot", "feet": return .init("length", "ft", UnitLength.feet)
case "yd", "yard", "yards": return .init("length", "yd", UnitLength.yards)
case "mi", "mile", "miles": return .init("length", "mi", UnitLength.miles)
case "mg", "milligram", "milligrams": return .init("mass", "mg", UnitMass.milligrams)
case "g", "gram", "grams": return .init("mass", "g", UnitMass.grams)
case "kg", "kilogram", "kilograms": return .init("mass", "kg", UnitMass.kilograms)
case "oz", "ounce", "ounces": return .init("mass", "oz", UnitMass.ounces)
case "lb", "lbs", "pound", "pounds": return .init("mass", "lb", UnitMass.pounds)
case "c", "°c", "celsius", "degrees celsius": return .init("temperature", "°C", UnitTemperature.celsius)
case "f", "°f", "fahrenheit", "degrees fahrenheit": return .init("temperature", "°F", fahrenheit)
case "k", "°k", "kelvin", "kelvins": return .init("temperature", "K", UnitTemperature.kelvin)
case "ml", "milliliter", "milliliters", "millilitre", "millilitres": return .init("volume", "mL", UnitVolume.milliliters)
case "l", "liter", "liters", "litre", "litres": return .init("volume", "L", UnitVolume.liters)
case "tsp", "teaspoon", "teaspoons", "us tsp", "us teaspoon", "us teaspoons": return usVolume("tsp", subdivisions: 768)
case "tbsp", "tablespoon", "tablespoons", "us tbsp", "us tablespoon", "us tablespoons": return usVolume("tbsp", subdivisions: 256)
case "fl oz", "floz", "fluid ounce", "fluid ounces", "us fl oz", "us fluid ounce", "us fluid ounces": return usVolume("fl oz", subdivisions: 128)
case "cup", "cups", "us cup", "us cups": return usVolume("cup", subdivisions: 16)
case "pt", "pint", "pints", "us pint", "us pints": return usVolume("pt", subdivisions: 8)
case "qt", "quart", "quarts", "us quart", "us quarts": return usVolume("qt", subdivisions: 4)
case "gal", "gallon", "gallons", "us gallon", "us gallons": return .init("volume", "gal", UnitVolume.gallons)
case "ms", "millisecond", "milliseconds": return .init("duration", "ms", scale: 0.001)
case "s", "sec", "secs", "second", "seconds": return .init("duration", "s", scale: 1)
case "min", "mins", "minute", "minutes": return .init("duration", "min", scale: 60)
case "h", "hr", "hrs", "hour", "hours": return .init("duration", "h", scale: 3600)
case "d", "day", "days": return .init("duration", "day", scale: 86400)
case "wk", "wks", "week", "weeks": return .init("duration", "week", scale: 604800)
case "m/s", "mps", "meter per second", "meters per second", "metre per second", "metres per second": return .init("speed", "m/s", UnitSpeed.metersPerSecond)
case "km/h", "km/hr", "kph", "kmph", "kilometer per hour", "kilometers per hour", "kilometre per hour", "kilometres per hour": return .init("speed", "km/h", UnitSpeed.kilometersPerHour)
case "mph", "mi/h", "mile per hour", "miles per hour": return .init("speed", "mph", UnitSpeed.milesPerHour)
case "m²", "m2", "sqm", "sq m", "square m", "square meter", "square meters", "square metre", "square metres": return .init("area", "m²", UnitArea.squareMeters)
case "cm²", "cm2", "sqcm", "sq cm", "square cm", "square centimeter", "square centimeters", "square centimetre", "square centimetres": return .init("area", "cm²", UnitArea.squareCentimeters)
case "km²", "km2", "sqkm", "sq km", "square km", "square kilometer", "square kilometers", "square kilometre", "square kilometres": return .init("area", "km²", UnitArea.squareKilometers)
case "ft²", "ft2", "sqft", "sq ft", "square ft", "square foot", "square feet": return .init("area", "ft²", UnitArea.squareFeet)
case "in²", "in2", "sqin", "sq in", "square in", "square inch", "square inches": return .init("area", "in²", UnitArea.squareInches)
default: return nil
}
}
}
}