feat: add calculator and unit conversion
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user