Phase 4 — Platform shells: - calcpad-macos/: SwiftUI two-column editor with Rust FFI bridge (16 files) - calcpad-windows/: iced GUI with Windows 11 Fluent theme (7 files, 13 tests) - calcpad-web/: React 18 + CodeMirror 6 + WASM Worker + PWA (20 files) - calcpad-cli/: clap-based CLI with expression eval, pipe/stdin, JSON/CSV output, and interactive REPL with rustyline history Phase 5 — Engine modules: - formatting/: answer formatting (decimal/scientific/SI notation, thousands separators, currency), line type classification, clipboard values (93 tests) - plugins/: CalcPadPlugin trait, PluginRegistry, Rhai scripting stub (43 tests) - benches/: criterion benchmarks (single-line, 100/500-line sheets, DAG, incremental) - tests/sheet_scenarios.rs: 20 real-world integration tests - tests/proptest_fuzz.rs: 12 property-based fuzz tests 771 tests passing across workspace, 0 failures.
93 lines
3.8 KiB
Swift
93 lines
3.8 KiB
Swift
import CCalcPadEngine
|
|
import Foundation
|
|
|
|
/// Calculation engine backed by the Rust CalcPad engine via C FFI.
|
|
///
|
|
/// Thread safety: Each call creates its own `EvalContext` on the Rust side,
|
|
/// so concurrent calls from different threads are safe. The C FFI functions
|
|
/// are documented as safe to call from any thread.
|
|
final class RustCalculationEngine: CalculationEngine, @unchecked Sendable {
|
|
|
|
private let decoder = JSONDecoder()
|
|
|
|
func evaluateLine(_ line: String) -> LineResult {
|
|
evaluateSingleLine(line, lineNumber: 1)
|
|
}
|
|
|
|
func evaluateSheet(_ text: String) -> [LineResult] {
|
|
let lines = text.components(separatedBy: "\n")
|
|
|
|
// Build C string array for the FFI call
|
|
let cStrings = lines.map { strdup($0) }
|
|
defer { cStrings.forEach { free($0) } }
|
|
|
|
var pointers = cStrings.map { UnsafePointer<CChar>($0) }
|
|
|
|
let resultPtr = pointers.withUnsafeMutableBufferPointer { buffer -> UnsafeMutablePointer<CChar>? in
|
|
calcpad_eval_sheet(buffer.baseAddress, Int32(lines.count))
|
|
}
|
|
|
|
guard let resultPtr else {
|
|
return lines.enumerated().map { index, line in
|
|
LineResult(id: index + 1, expression: line, result: "Error", isError: true)
|
|
}
|
|
}
|
|
defer { calcpad_free_result(resultPtr) }
|
|
|
|
let jsonString = String(cString: resultPtr)
|
|
|
|
guard let jsonData = jsonString.data(using: .utf8),
|
|
let response = try? decoder.decode(FFISheetResponse.self, from: jsonData) else {
|
|
return lines.enumerated().map { index, line in
|
|
LineResult(id: index + 1, expression: line, result: "Error", isError: true)
|
|
}
|
|
}
|
|
|
|
return response.results.enumerated().map { index, calcResult in
|
|
mapToLineResult(calcResult, expression: lines[index], lineNumber: index + 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func evaluateSingleLine(_ line: String, lineNumber: Int) -> LineResult {
|
|
let resultPtr = calcpad_eval_line(line)
|
|
|
|
guard let resultPtr else {
|
|
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
|
|
}
|
|
defer { calcpad_free_result(resultPtr) }
|
|
|
|
let jsonString = String(cString: resultPtr)
|
|
|
|
guard let jsonData = jsonString.data(using: .utf8),
|
|
let response = try? decoder.decode(FFIResponse.self, from: jsonData) else {
|
|
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
|
|
}
|
|
|
|
return mapToLineResult(response.result, expression: line, lineNumber: lineNumber)
|
|
}
|
|
|
|
private func mapToLineResult(_ calcResult: FFICalcResult, expression: String, lineNumber: Int) -> LineResult {
|
|
switch calcResult.value {
|
|
case .error(let message):
|
|
// Blank/comment lines produce "empty expression" errors in the Rust engine.
|
|
// Map these to nil result (matching the protocol's convention for non-evaluable lines).
|
|
if isEmptyExpressionError(message) {
|
|
return LineResult(id: lineNumber, expression: expression, result: nil, isError: false)
|
|
}
|
|
return LineResult(id: lineNumber, expression: expression, result: "Error: \(message)", isError: true)
|
|
|
|
default:
|
|
return LineResult(id: lineNumber, expression: expression, result: calcResult.metadata.display, isError: false)
|
|
}
|
|
}
|
|
|
|
/// The Rust engine returns an error for blank lines, comments, and other
|
|
/// non-evaluable input. We detect these to return nil (matching protocol convention).
|
|
private func isEmptyExpressionError(_ message: String) -> Bool {
|
|
let lower = message.lowercased()
|
|
return lower.contains("empty") || lower.contains("no expression") || lower.contains("comment")
|
|
}
|
|
}
|