Compare commits

..

2 Commits

7 changed files with 1014 additions and 26 deletions
+39 -4
View File
@@ -30,9 +30,9 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
## Controls ## Controls
- Type to filter applications; the first match is selected automatically. - Type to filter applications, calculate an expression, or convert units and currencies; the first result is selected automatically.
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text. - Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
- Return, keypad Enter, the Open Application button, or a double-click opens the selection. - Return, keypad Enter, the footer button, or a double-click opens the selected application or copies a calculator answer and dismisses the launcher.
- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search. - Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search.
- Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back. - Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back.
- Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it. - Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
@@ -75,6 +75,41 @@ Background discovery preserves the selected application even if its row changes.
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes. Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
## Calculator, unit and currency conversion
Type directly into search, as with [Raycast's calculator](https://manual.raycast.com/calculator). A recognized expression or conversion replaces application results with one **Calculator** or **Daily rates** row. Return, keypad Enter, double-click, or **Copy Answer** copies the displayed answer and dismisses the launcher, restoring the previously active application. Conversion answers include the destination unit or currency code. Application actions are disabled for calculator results; calculations do not affect launch ranking.
Arithmetic supports `+`, `-`, `*` (also `x` or `×`), `/` (also `÷`), parentheses, scientific notation, and right-associative `^` powers. Powers bind more tightly than unary signs: `-2^2` is `-4`, while `(-2)^2` is `4`. Functions are `sqrt`, `abs`, `round`, `floor`, `ceil`, `sin`, `cos`, `tan`, `log` (base ten), and `ln`; use parentheses around their arguments. Trigonometric arguments are radians; constants are `pi`/`π` and `e`. A trailing `%` divides by 100; `%` before an unsigned operand is remainder, and `mod` accepts signed operands. Percentage queries such as `52% of 900` and `20% off 80` also work.
Convert with `<expression> <source unit> to <target unit>`; `in` and `as` also work. Spaces between numbers and units are optional, and most units accept abbreviations and singular/plural English names.
| Unit family | Supported units | Example query |
| --- | --- | --- |
| Length | mm, cm, m, km, in, ft, yd, mi | `10ft in m` |
| Mass | mg, g, kg, oz, lb | `1 kg to pounds` |
| Temperature | Celsius, Fahrenheit, Kelvin | `32 f to c` |
| Volume | mL, L, US teaspoon, tablespoon, fluid ounce, cup, pint, quart, gallon | `1 cup to fl oz` |
| Duration | milliseconds, seconds, minutes, hours, days, weeks | `2 hours in minutes` |
| Speed | m/s, km/h, mph | `60 mph to km/h` |
| Area | cm², m², km², in², ft²; also `square` names | `144in² to ft²` |
| Data | bits/bytes, decimal kilo/mega/giga/tera and binary kibi/mebi/gibi/tebi | `1 GiB to MiB` |
Data abbreviations distinguish lowercase `b` (bits) from uppercase `B` (bytes): `8b to B` gives `1 B`. Decimal `KB` is 1,000 bytes; binary `KiB` is 1,024. Culinary volumes use US customary measures, not imperial measures or the 240 mL legal cup.
Arithmetic and physical/data unit conversion are offline, with no added dependencies or script evaluation. Output uses a decimal point and up to 12 significant digits; physical units generally use Foundation's native conversion precision, with exact defining Fahrenheit ratios and consistent US customary volume subdivisions. Division by zero, invalid function domains, nonfinite results, and incompatible unit families show a non-copyable error. Ordinary app names, standalone numeric literals, incomplete expressions, and unsupported syntax remain application searches. Input is bounded to 512 UTF-8 bytes and recursive parser depth below 16 to keep live search safe.
### Daily currency rates
Convert traditional currencies and crypto with currency codes, for example `100 USD to DKK`, `50 eur in usd`, `0.5 BTC to ETH`, or `(100 + 25) GBP as EUR`. Codes are case-insensitive; `to`, `in`, and `as` work just like unit conversions. Answers include the destination code when copied. Supported codes depend on the provider's available rates; unsupported currency codes show a non-copyable error rather than an invented rate.
Rates come from the community-maintained [currency-api](https://github.com/fawazahmed0/exchange-api), using its daily EUR-based snapshot for both fiat and crypto conversions. These are **daily rates, not live market prices**. The provider's actual snapshot date is shown in the result detail; it is a data date, not a market quote timestamp. Treat the result as an estimate, not a guaranteed trading or payment quote.
A complete, recognized currency query fetches rates over the internet without an account or API key. While loading, there is no copyable answer. Conjure keeps one snapshot in memory and reuses it across queries, with at most one fetch in flight. The next recognized currency query after the local day changes refreshes the cache; there is no background polling or fetch per keystroke. Cached answers keep their snapshot date visible, and an older cache is marked stale while refreshing. The cache is not saved across app restarts.
If fetching fails, Conjure shows a non-copyable currency error instead of silently using old or fake rates; any retained snapshot date remains visible. Change the query to retry. Application searches, arithmetic, and physical/data unit conversions still work offline.
This is not full Raycast parity: calendar/date/time-zone calculations, design units, and unrestricted natural-language math are not supported.
## Application actions and uninstall ## Application actions and uninstall
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application. Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
@@ -112,7 +147,7 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView`
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection. AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
- `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching. - `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching.
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery. - `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, calculator and unit/currency conversion, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks. - `Tests/ConjureKitTests`: deterministic discovery, ranking, history, calculator precedence/conversion/error boundaries, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks.
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)). The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
+115 -14
View File
@@ -7,10 +7,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let tableView = ApplicationTableView() private let tableView = ApplicationTableView()
private let statusLabel = NSTextField(labelWithString: "Finding applications…") private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…") private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
private let sectionLabel = NSTextField(labelWithString: "Applications")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil) private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory private let history: LaunchHistory
private var applications: [Application] = [] private var applications: [Application] = []
private var results: [Application] = [] private var results: [Application] = []
private var calculation: CalculatorResult?
private var isCurrencyCalculation = false
private var currencyRates: CurrencyRates?
private var currencyFetchedAt: Date?
private var currencyTask: Task<Void, Never>?
private var currencyError: String?
private var currencyFailureQuery: String?
private var locationLabels: [URL: String] = [:] private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>? private var discoveryTask: Task<Void, Never>?
private var isLaunching = false private var isLaunching = false
@@ -71,6 +79,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
fatalError("Conjure creates its window programmatically") fatalError("Conjure creates its window programmatically")
} }
deinit {
currencyTask?.cancel()
}
func present() { func present() {
if launcherFrameBeforeReview != nil { if launcherFrameBeforeReview != nil {
showWindow(nil) showWindow(nil)
@@ -179,7 +191,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let searchFont = NSFont.systemFont(ofSize: 20) let searchFont = NSFont.systemFont(ofSize: 20)
searchField.font = searchFont searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString( searchField.placeholderAttributedString = NSAttributedString(
string: "Search for apps…", string: "Search apps, calculate, or convert…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont] attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
) )
searchField.textColor = SunsetPalette.text searchField.textColor = SunsetPalette.text
@@ -193,7 +205,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.sendsSearchStringImmediately = true searchField.sendsSearchStringImmediately = true
searchField.sendsWholeSearchString = false searchField.sendsWholeSearchString = false
searchField.delegate = self searchField.delegate = self
searchField.setAccessibilityLabel("Search applications") searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
let symbol = NSImageView() let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
@@ -204,7 +216,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
header.alignment = .centerY header.alignment = .centerY
header.spacing = 12 header.spacing = 12
let sectionLabel = NSTextField(labelWithString: "Applications")
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold) sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
sectionLabel.textColor = SunsetPalette.secondaryText sectionLabel.textColor = SunsetPalette.secondaryText
@@ -347,6 +358,28 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
} }
private func refreshCurrencyRates() {
guard currencyTask == nil else { return }
currencyTask = Task { [weak self] in
let result: Result<CurrencyRates, any Error>
do { result = .success(try await CurrencyAPI.fetch()) }
catch { result = .failure(error) }
guard !Task.isCancelled, let self else { return }
self.currencyTask = nil
switch result {
case .success(let rates):
self.currencyRates = rates
self.currencyFetchedAt = Date()
self.currencyError = nil
self.currencyFailureQuery = nil
case .failure(let error):
self.currencyError = error.localizedDescription
self.currencyFailureQuery = self.searchField.stringValue
}
self.updateResults(preservingSelection: true, allowingCurrencyFetch: false)
}
}
private func updateLocationLabels() { private func updateLocationLabels() {
var nameCounts: [String: Int] = [:] var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count) nameCounts.reserveCapacity(applications.count)
@@ -369,22 +402,58 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
} }
private func updateResults(preservingSelection: Bool = false) { private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow) let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil ? results[tableView.selectedRow].id : nil
if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let query = searchField.stringValue
if currencyFailureQuery != query {
currencyError = nil
currencyFailureQuery = nil
}
isCurrencyCalculation = Calculator.isCurrencyQuery(query, currencyRates: currencyRates)
let needsCurrencyRefresh = isCurrencyCalculation && (currencyFetchedAt.map {
!Calendar.current.isDate($0, inSameDayAs: Date())
} ?? true)
if allowingCurrencyFetch, needsCurrencyRefresh, currencyError == nil {
refreshCurrencyRates()
}
calculation = Calculator.evaluate(query, currencyRates: currencyRates)
if isCurrencyCalculation, let currencyError {
let cached = currencyRates.map { " Cached snapshot \($0.date) is not used." } ?? ""
calculation = CalculatorResult(
displayValue: "Currency rates unavailable", copyValue: nil,
detail: "Daily rates: \(currencyError). Change the query to retry.\(cached)"
)
} else if isCurrencyCalculation, currencyRates != nil, let answer = calculation {
let cacheStatus = needsCurrencyRefresh
? (currencyTask == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
: "Cached daily rates"
calculation = CalculatorResult(
displayValue: answer.displayValue, copyValue: answer.copyValue,
detail: "\(answer.detail) · \(cacheStatus)"
)
}
if calculation != nil {
results = []
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
results = history.suggestions(in: applications) results = history.suggestions(in: applications)
} else { } else {
results = history.results(for: searchField.stringValue, in: applications) results = history.results(for: searchField.stringValue, in: applications)
} }
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
: calculation == nil ? "Applications" : "Calculator"
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
: calculation == nil ? "Applications" : "Calculator result")
tableView.reloadData() tableView.reloadData()
if !results.isEmpty { if resultCount > 0 {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0 let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row) tableView.scrollRowToVisible(row)
} }
emptyLabel.isHidden = !results.isEmpty emptyLabel.isHidden = resultCount > 0
if discoveryTask != nil { if let calculation {
statusLabel.stringValue = calculation.detail
} else if discoveryTask != nil {
emptyLabel.stringValue = "Finding applications…" emptyLabel.stringValue = "Finding applications…"
statusLabel.stringValue = "Finding applications…" statusLabel.stringValue = "Finding applications…"
} else { } else {
@@ -414,16 +483,24 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true return true
} }
private var resultCount: Int { calculation == nil ? results.count : 1 }
private func moveSelection(by offset: Int) { private func moveSelection(by offset: Int) {
guard !results.isEmpty else { return } guard resultCount > 0 else { return }
let row = min(max(tableView.selectedRow + offset, 0), results.count - 1) let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row) tableView.scrollRowToVisible(row)
} }
private func updateOpenButton() { private func updateOpenButton() {
openButton.isEnabled = !isLaunching && launcherFrameBeforeReview == nil && results.indices.contains(tableView.selectedRow) let canAct = !isLaunching && launcherFrameBeforeReview == nil && tableView.selectedRow >= 0
actionsButton.isEnabled = openButton.isEnabled openButton.title = calculation == nil ? "Open Application" : "Copy Answer"
openButton.toolTip = calculation == nil
? "Open the selected application (Return). Use ↑ and ↓ to select."
: "Copy the answer to the clipboard (Return)."
openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil }
?? results.indices.contains(tableView.selectedRow))
actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow)
} }
func tableViewSelectionDidChange(_ notification: Notification) { func tableViewSelectionDidChange(_ notification: Notification) {
@@ -431,20 +508,33 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
func numberOfRows(in tableView: NSTableView) -> Int { func numberOfRows(in tableView: NSTableView) -> Int {
results.count resultCount
} }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("applicationCell") let identifier = NSUserInterfaceItemIdentifier("applicationCell")
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell) let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
?? makeCell(identifier: identifier) ?? makeCell(identifier: identifier)
if let calculation {
cell.textField?.stringValue = calculation.displayValue
cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)?
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = calculation.detail
} else {
let application = results[row] let application = results[row]
cell.textField?.stringValue = application.name cell.textField?.stringValue = application.name
let location = locationLabels[application.id] let location = locationLabels[application.id]
cell.locationLabel.stringValue = location ?? "" cell.locationLabel.stringValue = location ?? ""
cell.locationLabel.isHidden = location == nil cell.locationLabel.isHidden = location == nil
cell.kindLabel.stringValue = "Application"
cell.imageView?.contentTintColor = nil
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path) cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path cell.toolTip = application.url.path
}
return cell return cell
} }
@@ -476,7 +566,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
title.orientation = .horizontal title.orientation = .horizontal
title.alignment = .centerY title.alignment = .centerY
title.spacing = 8 title.spacing = 8
let kind = NSTextField(labelWithString: "Application") let kind = cell.kindLabel
kind.font = .systemFont(ofSize: 14) kind.font = .systemFont(ofSize: 14)
kind.textColor = SunsetPalette.secondaryText kind.textColor = SunsetPalette.secondaryText
kind.setContentCompressionResistancePriority(.required, for: .horizontal) kind.setContentCompressionResistancePriority(.required, for: .horizontal)
@@ -582,6 +672,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
@objc private func openSelection() { @objc private func openSelection() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
if pasteboard.setString(value, forType: .string) {
dismiss()
}
return
}
guard results.indices.contains(tableView.selectedRow) else { return } guard results.indices.contains(tableView.selectedRow) else { return }
openApplication(results[tableView.selectedRow]) openApplication(results[tableView.selectedRow])
} }
@@ -622,6 +722,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@MainActor @MainActor
private final class ApplicationCell: NSTableCellView { private final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "") let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application")
} }
@MainActor @MainActor
+451
View File
@@ -0,0 +1,451 @@
import Foundation
public struct CalculatorResult: Sendable {
public let displayValue: String
public let copyValue: String?
public let detail: String
public init(displayValue: String, copyValue: String?, detail: String) {
self.displayValue = displayValue
self.copyValue = copyValue
self.detail = detail
}
}
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? {
evaluate(query, currencyRates: nil)
}
public static func isCurrencyQuery(_ query: String, currencyRates: CurrencyRates? = nil) -> Bool {
currencyQuery(query, rates: currencyRates) != nil
}
public static func evaluate(_ query: String, currencyRates: CurrencyRates?) -> CalculatorResult? {
guard query.utf8.count <= 512 else { return nil }
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return nil }
if let currency = currencyQuery(query, rates: currencyRates) {
let detail = "\(currency.expression) \(currency.source.uppercased()) → \(currency.target.uppercased())"
if let error = currency.amount.error { return failure(error, detail: detail) }
guard let currencyRates else { return failure("Loading currency rates…", detail: detail) }
let datedDetail = "\(detail) · Daily snapshot \(currencyRates.date)"
guard let source = currencyRates.rates[currency.source],
let target = currencyRates.rates[currency.target] else {
return failure("Unsupported currency", detail: datedDetail)
}
guard source.isFinite, source > 0, target.isFinite, target > 0 else {
return failure("Invalid currency rates", detail: datedDetail)
}
let value = currency.amount.value * target / source
guard value.isFinite else { return failure("Result is not finite", detail: datedDetail) }
let text = "\(format(value)) \(currency.target.uppercased())"
return CalculatorResult(displayValue: text, copyValue: text, detail: datedDetail)
}
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 struct CurrencyQuery {
let expression: String
let source: String
let target: String
let amount: Parser
}
private static let cryptoCodes: Set<String> = [
"1inch", "aave", "atom", "avax", "bake", "btcb", "busd", "cake", "celo", "comp",
"dash", "doge", "dydx", "egld", "eurc", "flow", "frax", "gala", "gusd", "hbar",
"kava", "klay", "link", "luna", "lunc", "mana", "mina", "near", "nexo", "ordi",
"paxg", "pepe", "qtum", "rune", "sand", "shib", "theta", "tusd", "usdc", "usdt",
"wbtc", "weth",
]
private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? {
let code = text.lowercased()
guard (2...12).contains(code.utf8.count),
code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }),
ConversionUnit.parse(text) == nil else { return nil }
let conventional = code.utf8.count == 3 && code.utf8.allSatisfy { (97...122).contains($0) }
return conventional || cryptoCodes.contains(code) || rates?.rates[code] != nil ? code : nil
}
private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? {
guard query.utf8.count <= 512 else { return nil }
let words = query.split(whereSeparator: \.isWhitespace)
guard words.count >= 3,
["to", "in", "as"].contains(words[words.count - 2].lowercased()),
let target = currencyCode(String(words[words.count - 1]), rates: rates) else { return nil }
let sourceText = words.dropLast(2).joined(separator: " ")
let characters = Array(sourceText)
guard characters.count > 1 else { return nil }
for length in stride(from: min(12, characters.count - 1), through: 2, by: -1) {
let suffix = String(characters.suffix(length))
guard let source = currencyCode(suffix, rates: rates) else { continue }
let boundary = characters[characters.count - length - 1]
// Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH.
guard boundary.isWhitespace || (suffix.allSatisfy(\.isLetter) && !boundary.isLetter) else { continue }
let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces)
guard let amount = parse(expression) else { continue }
return CurrencyQuery(expression: expression, source: source, target: target, amount: amount)
}
return nil
}
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
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import Foundation
public struct CurrencyRates: Sendable {
/// The provider's daily snapshot date, not a market quote timestamp.
public let date: String
/// Units of each lowercase currency code per EUR.
public let rates: [String: Double]
public init(date: String, rates: [String: Double]) {
self.date = date
self.rates = rates
}
}
public enum CurrencyAPI {
private struct Snapshot: Decodable {
let date: String
let eur: [String: Double]
}
private enum FetchError: LocalizedError {
case invalidResponse
case http(Int)
case invalidSnapshot
case unavailable(String, String)
var errorDescription: String? {
switch self {
case .invalidResponse: "Currency provider returned a non-HTTP response."
case .http(let status): "Currency provider returned HTTP \(status)."
case .invalidSnapshot: "Currency provider returned an invalid daily snapshot."
case .unavailable(let primary, let fallback):
"Currency rates unavailable. CDN: \(primary) Fallback: \(fallback)"
}
}
}
public static func fetch() async throws -> CurrencyRates {
try await fetch { request in
try await URLSession.shared.data(for: request)
}
}
// The provider documents this CDN → Cloudflare fallback:
// https://github.com/fawazahmed0/exchange-api#additional-fallback-url-on-cloudflare
// Inject only the network boundary so tests never make real requests.
static func fetch(
load: @Sendable (URLRequest) async throws -> (Data, URLResponse)
) async throws -> CurrencyRates {
let endpoints = [
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json",
"https://latest.currency-api.pages.dev/v1/currencies/eur.json",
]
var primaryFailure = ""
for (index, endpoint) in endpoints.enumerated() {
try Task.checkCancellation()
let request = URLRequest(url: URL(string: endpoint)!, cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 12)
do {
let (data, response) = try await load(request)
try Task.checkCancellation()
guard let response = response as? HTTPURLResponse else { throw FetchError.invalidResponse }
guard (200...299).contains(response.statusCode) else { throw FetchError.http(response.statusCode) }
let snapshot: Snapshot
do { snapshot = try JSONDecoder().decode(Snapshot.self, from: data) }
catch { throw FetchError.invalidSnapshot }
guard validDate(snapshot.date), snapshot.eur["eur"] == 1,
snapshot.eur.allSatisfy({ code, rate in
!code.isEmpty && code.utf8.allSatisfy {
(97...122).contains($0) || (48...57).contains($0)
} && rate.isFinite && rate > 0
}) else { throw FetchError.invalidSnapshot }
return CurrencyRates(date: snapshot.date, rates: snapshot.eur)
} catch {
try Task.checkCancellation()
if error is CancellationError || (error as? URLError)?.code == .cancelled { throw error }
if index == 0 { primaryFailure = error.localizedDescription }
else { throw FetchError.unavailable(primaryFailure, error.localizedDescription) }
}
}
throw FetchError.invalidResponse
}
private static func validDate(_ text: String) -> Bool {
let bytes = Array(text.utf8)
guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45,
bytes.enumerated().allSatisfy({ index, byte in
index == 4 || index == 7 || (48...57).contains(byte)
}), let year = Int(text.prefix(4)), year > 0,
let month = Int(text.dropFirst(5).prefix(2)),
let day = Int(text.suffix(2)) else { return false }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let components = DateComponents(year: year, month: month, day: day)
guard let date = calendar.date(from: components) else { return false }
let actual = calendar.dateComponents([.year, .month, .day], from: date)
return actual.year == year && actual.month == month && actual.day == day
}
}
+1 -1
View File
@@ -4,4 +4,4 @@
- show in finder - show in finder
- reset ranking - reset ranking
- quit application - quit application
- [ ] add unit conversion and calculator behavior like raylib has. - [x] add unit conversion and calculator behavior like raylib has.
+104
View File
@@ -0,0 +1,104 @@
import ConjureKit
import Foundation
import Testing
@Test
func calculatorHonorsPrecedenceUnaryAndRightAssociativePowers() {
let examples: [(String, String)] = [
("2 + 3 * 4", "14"), ("(2 + 3) * 4", "20"),
("2^3^2", "512"), ("-2^2", "-4"), ("(-2)^2", "4"),
("2^-2", "0.25"), ("--2 + +3", "5"),
("8 ÷ 2 × 3", "12"), ("2x3", "6"), ("2 x (3 + 1)", "8"),
("10 % 3", "1"), ("10 mod -3", "1"),
]
for (query, expected) in examples {
let result = Calculator.evaluate(query)
#expect(result?.displayValue == expected, "\(query)")
#expect(result?.copyValue == expected, "\(query)")
}
}
@Test
func calculatorSupportsFunctionsPercentagesAndScientificNotation() {
let examples: [(String, String)] = [
("sqrt(81) + abs(-3)", "12"), ("round(2.5) + floor(2.9) + ceil(2.1)", "8"),
("sin(pi/2) + cos(0) + tan(0)", "2"), ("log(100) + ln(e)", "3"),
("50%", "0.5"), ("200 * 10%", "20"), ("50% + 2", "2.5"),
("52% of 900", "468"), ("20% off 80", "64"), ("20% off (80 + 20)", "80"),
(".5e2 + 1E-2", "50.01"), ("π - pi", "0"),
("pi", "3.14159265359"), ("e", "2.71828182846"), ("1 / 3", "0.333333333333"),
]
for (query, expected) in examples {
#expect(Calculator.evaluate(query)?.copyValue == expected, "\(query)")
}
}
@Test
func calculatorConvertsDimensionsAndTemperatureOffsets() throws {
let examples: [(String, Double, String)] = [
("10 km to mi", 6.21371192237, "mi"), ("10ft in m", 3.048, "m"),
("29 inches to cm", 73.66, "cm"), ("2 * 5 cm to inches", 3.93700787402, "in"),
("1 kilograms to pounds", 2.20462262185, "lb"), ("16oz to lb", 1, "lb"),
("32 f to c", 0, "°C"), ("23C to F", 73.4, "°F"),
("100°C to °F", 212, "°F"), ("0 c to kelvin", 273.15, "K"),
("2 hours in minutes", 120, "min"), ("1 week as seconds", 604800, "s"),
("1000 milliseconds to s", 1, "s"),
("36 km/h to m/s", 10, "m/s"), ("60 mph to km/h", 96.56064, "km/h"),
("1 square m to square cm", 10000, "cm²"), ("144in² to ft²", 1, "ft²"),
("2 liters to ml", 2000, "mL"), ("1 US gallon to l", 3.785411784, "L"),
("1 tbsp to tsp", 3, "tsp"), ("1 cup to fl oz", 8, "fl oz"),
]
for (query, expected, unit) in examples {
let result = try #require(Calculator.evaluate(query), "\(query)")
let copied = try #require(result.copyValue, "\(query): \(result.displayValue)")
#expect(copied.hasSuffix(" \(unit)"), "\(query)")
let value = try #require(Double(copied.dropLast(unit.count + 1)), "\(query)")
// Foundation's built-in physical-unit coefficients have limited decimal precision.
#expect(abs(value - expected) < max(1e-9, abs(expected) * 1e-5), "\(query): \(copied)")
}
#expect(Calculator.evaluate("32 f to c")?.copyValue == "0 °C")
#expect(Calculator.evaluate("0 c to f")?.copyValue == "32 °F")
}
@Test
func calculatorDistinguishesDecimalBinaryAndBitsFromBytes() {
let examples: [(String, String)] = [
("1 GB to MB", "1000 MB"), ("1GB to B", "1000000000 B"),
("1 GiB to MiB", "1024 MiB"), ("1 KiB to B", "1024 B"),
("1 MB to KiB", "976.5625 KiB"), ("8b to B", "1 B"),
("1 B to b", "8 b"), ("1 Mb to MB", "0.125 MB"), ("8 bits to bytes", "1 B"),
("1 kilobyte to bytes", "1000 B"), ("1 mebibyte to KiB", "1024 KiB"),
("8 megabits to MB", "1 MB"),
]
for (query, expected) in examples {
#expect(Calculator.evaluate(query)?.copyValue == expected, "\(query)")
}
}
@Test
func calculatorLeavesApplicationQueriesAndIncompleteInputAlone() {
for query in ["", "Safari", "Xcode", "1Password", "Calculator", "123", "-123", "1e3",
"2 +", "sqrt(", "2^", "1e+", "(1+2", "2 3", "2 ** 3", "pi app", "sin 2",
"alert(1)", "foo(2)", "1/0 Safari", "10 km to", "10 km to widgets", "2 of 3", "20 off 80"] {
#expect(Calculator.evaluate(query) == nil, "\(query)")
}
}
@Test
func calculatorReportsRecognizedErrorsWithoutCopyableResults() throws {
for query in ["1/0", "1 mod 0", "sqrt(-1)", "ln(0)", "log(-2)", "(-2)^0.5",
"1e308 * 1e308", "1e999 + 1", "1e308 TB to B", "10 m to kg", "1 GB to seconds"] {
let result = try #require(Calculator.evaluate(query), "\(query)")
#expect(result.copyValue == nil, "\(query)")
}
}
@Test
func calculatorBoundsLiveSearchInputAndRecursiveGrammar() {
#expect(Calculator.evaluate(String(repeating: "(", count: 70) + "1" + String(repeating: ")", count: 70)) == nil)
#expect(Calculator.evaluate(String(repeating: "(", count: 16) + "1" + String(repeating: ")", count: 16)) == nil)
#expect(Calculator.evaluate(String(repeating: "-", count: 70) + "1 + 2") == nil)
#expect(Calculator.evaluate(String(repeating: "2^", count: 70) + "2") == nil)
#expect(Calculator.evaluate(String(repeating: "1+", count: 300) + "1") == nil)
#expect(Calculator.evaluate(String(repeating: "(", count: 15) + "1+2" + String(repeating: ")", count: 15))?.copyValue == "3")
}
+198
View File
@@ -0,0 +1,198 @@
@testable import ConjureKit
import Foundation
import Testing
@Test
func calculatorCrossConvertsCurrencyExpressionsAndPreservesSnapshotDate() throws {
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "usd": 2, "dkk": 8])
let result = try #require(Calculator.evaluate("2*50 USD to DKK", currencyRates: snapshot))
#expect(result.displayValue == "400 DKK")
#expect(result.copyValue == "400 DKK")
#expect(result.detail == "2*50 USD → DKK · Daily snapshot 2026-01-02")
}
@Test
func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() {
let snapshot = CurrencyRates(date: "2026-01-02", rates: [
"eur": 1, "usd": 2, "dkk": 8, "btc": 0.00002, "eth": 0.0005,
"sol": 0.01, "1inch": 4, "arb": 3, "op": 5,
])
let examples = [
("100usd IN dKk", "400 DKK"),
("8 DKK as USD", "2 USD"),
("1 BTC to usd", "100000 USD"),
("10 EUR to ETH", "0.005 ETH"),
("2 sol to EUR", "200 EUR"),
("2 1INCH to usd", "1 USD"),
("2 USD to 1inch", "4 1INCH"),
("2 op to USD", "0.8 USD"),
("sqrt(16) usd to eur", "2 EUR"),
("-2 USD to DKK", "-8 DKK"),
("0 USD to DKK", "0 DKK"),
("1 USD to USD", "1 USD"),
]
for (query, expected) in examples {
#expect(Calculator.isCurrencyQuery(query, currencyRates: snapshot), "\(query)")
#expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected, "\(query)")
}
#expect(!Calculator.isCurrencyQuery("2 op to USD"))
}
@Test
func calculatorReportsLoadingUnsupportedCurrenciesAndInvalidAmountsWithoutCopying() throws {
let loading = try #require(Calculator.evaluate("100 usd to dkk"))
#expect(loading.displayValue == "Loading currency rates…")
#expect(loading.copyValue == nil)
#expect(Calculator.isCurrencyQuery("2 1INCH to USD"))
#expect(Calculator.evaluate("2 1INCH to USD")?.copyValue == nil)
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "usd": 2])
let unsupported = try #require(Calculator.evaluate("1 USD to XYZ", currencyRates: snapshot))
#expect(unsupported.displayValue == "Unsupported currency")
#expect(unsupported.copyValue == nil)
#expect(unsupported.detail.contains("2026-01-02"))
for query in ["1/0 USD to EUR", "sqrt(-1) USD to EUR", "1e999 USD to EUR", "1e308 USD to XYZ"] {
#expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == nil, "\(query)")
}
let broken = CurrencyRates(date: "2026-01-02", rates: ["usd": 0, "eur": 1])
#expect(Calculator.evaluate("1 USD to EUR", currencyRates: broken)?.displayValue == "Invalid currency rates")
let huge = CurrencyRates(date: "2026-01-02", rates: ["usd": 1, "eur": 1, "dkk": 8])
#expect(Calculator.evaluate("1e308 USD to DKK", currencyRates: huge)?.displayValue == "Result is not finite")
}
@Test
func calculatorCurrencyParsingPreservesPhysicalAndApplicationQueries() {
let snapshot = CurrencyRates(date: "2026-01-02", rates: [
"eur": 1, "usd": 2, "cup": 24, "sec": 3, "min": 4,
])
for query in ["Safari", "1Password", "2 Safari to Chrome", "USD to DKK", "2 USD to",
"2 USD", "2 + USD to EUR", "2 USD to EUR app", "2 foo USD to EUR",
"2 1inchh to USD", "21inch to USD", "1 cup to USD", "1 USD to cup",
"1 km to mi", "1 sec to min", "1 GB to seconds", "2 hours in minutes"] {
#expect(!Calculator.isCurrencyQuery(query, currencyRates: snapshot), "\(query)")
}
for query in ["Safari", "1Password", "2 Safari to Chrome", "USD to DKK", "2 USD to",
"2 USD", "2 + USD to EUR", "2 USD to EUR app", "2 foo USD to EUR",
"2 1inchh to USD", "21inch to USD", "1 cup to USD", "1 USD to cup"] {
#expect(Calculator.evaluate(query, currencyRates: snapshot) == nil, "\(query)")
}
#expect(Calculator.evaluate("1 sec to min", currencyRates: snapshot)?.copyValue == "0.0166666666667 min")
#expect(Calculator.evaluate("2 hours in minutes", currencyRates: snapshot)?.copyValue == "120 min")
#expect(Calculator.evaluate("2 + 3", currencyRates: snapshot)?.copyValue == "5")
#expect(!Calculator.isCurrencyQuery(String(repeating: "1+", count: 300) + "1 USD to EUR"))
}
private actor CurrencyFixture {
enum Reply: Sendable {
case response(Int, String)
case failure(URLError.Code)
}
let replies: [Reply]
private(set) var requests: [URLRequest] = []
init(_ replies: [Reply]) { self.replies = replies }
func load(_ request: URLRequest) throws -> (Data, URLResponse) {
requests.append(request)
guard requests.count <= replies.count else { throw URLError(.badServerResponse) }
switch replies[requests.count - 1] {
case .response(let status, let json):
return (Data(json.utf8), HTTPURLResponse(url: request.url!, statusCode: status,
httpVersion: nil, headerFields: nil)!)
case .failure(let code): throw URLError(code)
}
}
}
private let validCurrencyJSON = #"{"date":"2026-01-02","eur":{"eur":1,"usd":2,"btc":0.00002,"1inch":4}}"#
@Test
func currencyFetchLoadsOneEURSnapshotWithBoundedTimeout() async throws {
let fixture = CurrencyFixture([.response(200, validCurrencyJSON)])
let snapshot = try await CurrencyAPI.fetch { try await fixture.load($0) }
#expect(snapshot.date == "2026-01-02")
#expect(snapshot.rates["usd"] == 2)
#expect(snapshot.rates["btc"] == 0.00002)
#expect(snapshot.rates["1inch"] == 4)
let requests = await fixture.requests
#expect(requests.count == 1)
#expect(requests.first?.url?.absoluteString == "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json")
#expect(requests.first?.timeoutInterval == 12)
#expect(requests.first?.cachePolicy == .reloadIgnoringLocalCacheData)
}
@Test
func currencyFetchFallsBackAfterTransportAndHTTPFailures() async throws {
for failure in [CurrencyFixture.Reply.failure(.timedOut), .response(503, "{}")] {
let fixture = CurrencyFixture([failure, .response(200, validCurrencyJSON)])
let snapshot = try await CurrencyAPI.fetch { try await fixture.load($0) }
#expect(snapshot.date == "2026-01-02")
let requests = await fixture.requests
#expect(requests.count == 2)
#expect(requests.last?.url?.absoluteString == "https://latest.currency-api.pages.dev/v1/currencies/eur.json")
#expect(requests.last?.timeoutInterval == 12)
}
}
@Test(arguments: [
"{}",
"not JSON",
#"{"date":"2026-01-02","eur":{"eur":1,"usd":"2"}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd":0}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd":-2}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd":1e999}}"#,
#"{"date":"2026-01-02","eur":{"eur":2,"usd":2}}"#,
#"{"date":"2026-01-02","eur":{"usd":2}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"USD":2}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd/eur":2}}"#,
#"{"date":"2026-02-30","eur":{"eur":1,"usd":2}}"#,
#"{"date":"2026-1-02","eur":{"eur":1,"usd":2}}"#,
#"{"date":"2026-13-02","eur":{"eur":1,"usd":2}}"#,
#"{"date":"0000-01-02","eur":{"eur":1,"usd":2}}"#,
])
func currencyFetchRejectsInvalidSnapshotsAndUsesFallback(_ json: String) async throws {
let fixture = CurrencyFixture([.response(200, json), .response(200, validCurrencyJSON)])
let snapshot = try await CurrencyAPI.fetch { try await fixture.load($0) }
#expect(snapshot.rates["usd"] == 2)
#expect(await fixture.requests.count == 2)
}
@Test
func currencyFetchReportsBothFailuresWithoutInventingRates() async {
let fixture = CurrencyFixture([.response(503, "{}"), .response(404, "{}")])
do {
_ = try await CurrencyAPI.fetch { try await fixture.load($0) }
Issue.record("Unavailable providers unexpectedly produced currency rates")
} catch {
#expect(error.localizedDescription.contains("HTTP 503"))
#expect(error.localizedDescription.contains("HTTP 404"))
}
#expect(await fixture.requests.count == 2)
}
@Test
func currencyFetchDoesNotRetryCancelledRequests() async {
let fixture = CurrencyFixture([.failure(.cancelled)])
do {
_ = try await CurrencyAPI.fetch { try await fixture.load($0) }
Issue.record("Cancelled request unexpectedly produced currency rates")
} catch {
#expect((error as? URLError)?.code == .cancelled)
}
#expect(await fixture.requests.count == 1)
}
@Test
func currencyFetchRejectsNonHTTPResponsesAndAcceptsValidLeapDayMetadata() async throws {
let fixture = CurrencyFixture([.response(200, #"{"date":"2024-02-29","eur":{"eur":1,"usd":2}}"#)])
let snapshot = try await CurrencyAPI.fetch { request in
if request.url?.host == "cdn.jsdelivr.net" {
return (Data(validCurrencyJSON.utf8),
URLResponse(url: request.url!, mimeType: "application/json",
expectedContentLength: 0, textEncodingName: nil))
}
return try await fixture.load(request)
}
#expect(snapshot.date == "2024-02-29")
#expect(await fixture.requests.count == 1)
}