Table formulas !24
19 files changed, +1676 −2
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +27
| @@ -316,6 +316,33 @@ public final class DocumentSession { | |||
| 316 | } | 316 | } |
| 317 | } | 317 | } |
| 318 | 318 | ||
| 319 | // MARK: - External work | ||
| 320 | |||
| 321 | /// Recalculates a table in Emacs from a snapshot of the buffer, then replaces the table | ||
| 322 | /// with Emacs's text if it hasn't changed meanwhile. Returns a message for the echo area. | ||
| 323 | public func recalculateInEmacs(table: Range<Int>, point: Int, form: String) async -> String { | ||
| 324 | guard let buffer, let path else { return "No file is open." } | ||
| 325 | let snapshot = buffer.document.text as NSString | ||
| 326 | guard table.upperBound <= snapshot.length else { return "The table moved; try again." } | ||
| 327 | let old = snapshot.substring(with: NSRange(table)) | ||
| 328 | let directory = URL(fileURLWithPath: path).deletingLastPathComponent() | ||
| 329 | do { | ||
| 330 | let new = try await EmacsRunner.recalculateTable(text: snapshot as String, directory: directory, table: table, point: point, form: form) | ||
| 331 | guard let current = self.buffer, self.path == path else { return "The file was closed before Emacs finished." } | ||
| 332 | let text = current.document.text as NSString | ||
| 333 | guard table.upperBound <= text.length, text.substring(with: NSRange(table)) == old else { | ||
| 334 | return "The table changed while Emacs recalculated it; nothing was changed." | ||
| 335 | } | ||
| 336 | guard new != old else { return "Recalculated in Emacs" } | ||
| 337 | let result = EditResult(baseRevision: current.document.revision, edits: [TextEdit(range: table, replacement: new)]) | ||
| 338 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } | ||
| 339 | bufferChanged() | ||
| 340 | return "Recalculated in Emacs" | ||
| 341 | } catch { | ||
| 342 | return "\(error)" | ||
| 343 | } | ||
| 344 | } | ||
| 345 | |||
| 319 | // MARK: - Locations | 346 | // MARK: - Locations |
| 320 | 347 | ||
| 321 | /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets | 348 | /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets |
Sources/OrgApp/EmacsRunner.swift added +82
| @@ -0,0 +1,82 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Runs `emacs --batch` for what the native code hands over, such as tables outside the | ||
| 4 | /// native formula domain. | ||
| 5 | public enum EmacsRunner { | ||
| 6 | public struct Failure: Error, CustomStringConvertible { | ||
| 7 | public let description: String | ||
| 8 | } | ||
| 9 | |||
| 10 | /// `ORGSTAR_EMACS`, then the usual install locations: an app launched from the Dock | ||
| 11 | /// doesn't see the shell's PATH. | ||
| 12 | public static func executable(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { | ||
| 13 | let candidates = [environment["ORGSTAR_EMACS"]].compactMap { $0 } + [ | ||
| 14 | "/opt/homebrew/bin/emacs", "/usr/local/bin/emacs", "/Applications/Emacs.app/Contents/MacOS/Emacs", | ||
| 15 | "/run/current-system/sw/bin/emacs", "/usr/bin/emacs", | ||
| 16 | ] | ||
| 17 | return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }.map(URL.init(fileURLWithPath:)) | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Emacs point for a UTF-16 offset. | ||
| 21 | static func point(_ offset: Int, in text: String) -> Int { | ||
| 22 | let index = String.Index(utf16Offset: offset, in: text) | ||
| 23 | return text.unicodeScalars[..<index].count + 1 | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Runs `form` with point at `point` in a copy of `text` whose directory is `directory`, | ||
| 27 | /// then returns the text of the table that starts at `table.lowerBound`. | ||
| 28 | public static func recalculateTable(text: String, directory: URL, table: Range<Int>, point: Int, form: String, timeout: Duration = .seconds(60)) async throws -> String { | ||
| 29 | guard let emacs = executable() else { throw Failure(description: "Emacs isn't installed, so this table can't be recalculated.") } | ||
| 30 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-emacs-\(UUID().uuidString)") | ||
| 31 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 32 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 33 | let input = folder.appendingPathComponent("input.org") | ||
| 34 | let output = folder.appendingPathComponent("output.org") | ||
| 35 | let script = folder.appendingPathComponent("recalc.el") | ||
| 36 | try text.write(to: input, atomically: true, encoding: .utf8) | ||
| 37 | func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" } | ||
| 38 | try """ | ||
| 39 | ;;; -*- lexical-binding: t -*- | ||
| 40 | (require 'org) | ||
| 41 | (require 'org-table) | ||
| 42 | (setq enable-local-variables :safe) | ||
| 43 | (with-temp-buffer | ||
| 44 | (let ((coding-system-for-read 'utf-8-unix)) | ||
| 45 | (insert-file-contents \(lisp(input.path)))) | ||
| 46 | (setq default-directory \(lisp(directory.path + "/"))) | ||
| 47 | (org-mode) | ||
| 48 | (goto-char \(Self.point(point, in: text))) | ||
| 49 | \(form) | ||
| 50 | (goto-char \(Self.point(table.lowerBound, in: text))) | ||
| 51 | (let ((coding-system-for-write 'utf-8-unix)) | ||
| 52 | (write-region (org-table-begin) (org-table-end) \(lisp(output.path))))) | ||
| 53 | """.write(to: script, atomically: true, encoding: .utf8) | ||
| 54 | |||
| 55 | let process = Process() | ||
| 56 | process.executableURL = emacs | ||
| 57 | process.arguments = ["-Q", "--batch", "-l", script.path] | ||
| 58 | process.currentDirectoryURL = directory | ||
| 59 | let errors = Pipe() | ||
| 60 | process.standardError = errors | ||
| 61 | process.standardOutput = FileHandle.nullDevice | ||
| 62 | try process.run() | ||
| 63 | let deadline = ContinuousClock.now + timeout | ||
| 64 | while process.isRunning { | ||
| 65 | if ContinuousClock.now > deadline { | ||
| 66 | process.terminate() | ||
| 67 | throw Failure(description: "Emacs took too long to recalculate the table.") | ||
| 68 | } | ||
| 69 | try await Task.sleep(for: .milliseconds(50)) | ||
| 70 | if Task.isCancelled { | ||
| 71 | process.terminate() | ||
| 72 | throw CancellationError() | ||
| 73 | } | ||
| 74 | } | ||
| 75 | guard process.terminationStatus == 0, let data = FileManager.default.contents(atPath: output.path) else { | ||
| 76 | let log = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) | ||
| 77 | let last = log.split(separator: "\n").last.map(String.init) ?? "Emacs failed" | ||
| 78 | throw Failure(description: last) | ||
| 79 | } | ||
| 80 | return String(decoding: data, as: UTF8.self) | ||
| 81 | } | ||
| 82 | } | ||
Sources/OrgApp/TrustStore.swift added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | import CryptoKit | ||
| 2 | import Foundation | ||
| 3 | |||
| 4 | /// Code the user agreed to run (Babel blocks, Lisp table formulas), keyed by file and content | ||
| 5 | /// hash: a change to the code asks again, as `org-confirm-babel-evaluate` would. | ||
| 6 | @MainActor | ||
| 7 | public final class TrustStore { | ||
| 8 | private let file: URL | ||
| 9 | private var hashes: Set<String> | ||
| 10 | |||
| 11 | public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("trusted.json")) { | ||
| 12 | self.file = file | ||
| 13 | hashes = (try? JSONDecoder().decode(Set<String>.self, from: Data(contentsOf: file))) ?? [] | ||
| 14 | } | ||
| 15 | |||
| 16 | static func key(path: String, content: String) -> String { | ||
| 17 | SHA256.hash(data: Data((path + "\u{0}" + content).utf8)).map { String(format: "%02x", $0) }.joined() | ||
| 18 | } | ||
| 19 | |||
| 20 | public func isTrusted(path: String, content: String) -> Bool { | ||
| 21 | hashes.contains(Self.key(path: path, content: content)) | ||
| 22 | } | ||
| 23 | |||
| 24 | public func trust(path: String, content: String) { | ||
| 25 | hashes.insert(Self.key(path: path, content: content)) | ||
| 26 | try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 27 | try? JSONEncoder().encode(hashes).write(to: file, options: .atomic) | ||
| 28 | } | ||
| 29 | } | ||
Sources/OrgCore/Commands/Archive.swift +1
| @@ -327,6 +327,7 @@ public enum Archive { | |||
| 327 | case .commit(let result): return .success(Edits(source: result, archive: archiveEdit)) | 327 | case .commit(let result): return .success(Edits(source: result, archive: archiveEdit)) |
| 328 | case .failed(let message): return .failure(.message(message)) | 328 | case .failed(let message): return .failure(.message(message)) |
| 329 | case .prompt: return .failure(.message("unexpected prompt")) | 329 | case .prompt: return .failure(.message("unexpected prompt")) |
| 330 | case .external: return .failure(.message("unexpected external request")) | ||
| 330 | } | 331 | } |
| 331 | } | 332 | } |
| 332 | } | 333 | } |
Sources/OrgCore/Commands/Command.swift +10
| @@ -137,6 +137,15 @@ public enum CommandStep: Sendable, Equatable { | |||
| 137 | /// The command can't run here; nothing changes. The message is for the user, as org's | 137 | /// The command can't run here; nothing changes. The message is for the user, as org's |
| 138 | /// `user-error`. | 138 | /// `user-error`. |
| 139 | case failed(String) | 139 | case failed(String) |
| 140 | /// Work the platform does outside the text (Emacs, an interpreter); its result comes back | ||
| 141 | /// as an edit. | ||
| 142 | case external(ExternalRequest) | ||
| 143 | } | ||
| 144 | |||
| 145 | public enum ExternalRequest: Sendable, Equatable { | ||
| 146 | /// Recalculate the table whose text is at `table` in Emacs, running `form` with point at | ||
| 147 | /// `point`, and put Emacs's table text in its place. `lisp` when its formulas run Lisp. | ||
| 148 | case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool) | ||
| 140 | } | 149 | } |
| 141 | 150 | ||
| 142 | /// A named operation. Keys, menus, the palette and touch controls all run commands. | 151 | /// A named operation. Keys, menus, the palette and touch controls all run commands. |
| @@ -162,6 +171,7 @@ public enum Commands { | |||
| 162 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), | 171 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), |
| 163 | InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), | 172 | InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), |
| 164 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), | 173 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), |
| 174 | TableRecalculate(all: false), TableRecalculate(all: true), | ||
| 165 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 175 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 166 | ] | 176 | ] |
| 167 | 177 | ||
Sources/OrgCore/Compute/Calc.swift added +600
| @@ -0,0 +1,600 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// The part of Emacs Calc that table formulas use, with Calc's number semantics: exact | ||
| 4 | /// integers, decimal floats rounded to `calc-internal-prec` digits after every operation, and | ||
| 5 | /// Calc's display formats. Anything outside this domain throws `Calc.Unsupported`, and the | ||
| 6 | /// caller hands the work to Emacs. | ||
| 7 | public enum Calc { | ||
| 8 | public struct Unsupported: Error, Equatable, CustomStringConvertible { | ||
| 9 | public let reason: String | ||
| 10 | public var description: String { reason } | ||
| 11 | init(_ reason: String) { self.reason = reason } | ||
| 12 | } | ||
| 13 | |||
| 14 | public enum Value: Equatable, Sendable { | ||
| 15 | case int(Int) | ||
| 16 | /// Mantissa and exponent, normalized: at most `precision` digits, no trailing zeros. | ||
| 17 | case float(Int, Int) | ||
| 18 | case vector([Value]) | ||
| 19 | } | ||
| 20 | |||
| 21 | public enum FloatFormat: Equatable, Sendable { | ||
| 22 | case float(Int), fix(Int), sci(Int), eng(Int) | ||
| 23 | } | ||
| 24 | |||
| 25 | public struct Modes: Equatable, Sendable { | ||
| 26 | public var precision = 12 | ||
| 27 | public var format = FloatFormat.float(8) | ||
| 28 | public var degrees = true | ||
| 29 | public init() {} | ||
| 30 | } | ||
| 31 | |||
| 32 | /// `calc-eval` of `expression`, as the string Calc would return. | ||
| 33 | public static func evaluate(_ expression: String, modes: Modes = Modes()) throws -> String { | ||
| 34 | guard modes.precision == 12 else { throw Unsupported("calc precision") } | ||
| 35 | var parser = Parser(expression) | ||
| 36 | let tree = try parser.parse() | ||
| 37 | let value = try Evaluator(modes: modes).eval(tree) | ||
| 38 | return try format(value, modes.format) | ||
| 39 | } | ||
| 40 | |||
| 41 | // MARK: - Numbers | ||
| 42 | |||
| 43 | static let precision = 12 | ||
| 44 | |||
| 45 | static func digits(_ x: Int128) -> Int { | ||
| 46 | var n = x.magnitude | ||
| 47 | var count = 1 | ||
| 48 | while n >= 10 { | ||
| 49 | n /= 10 | ||
| 50 | count += 1 | ||
| 51 | } | ||
| 52 | return count | ||
| 53 | } | ||
| 54 | |||
| 55 | static func power10(_ n: Int) throws -> Int128 { | ||
| 56 | guard n >= 0, n <= 37 else { throw Unsupported("number size") } | ||
| 57 | var result: Int128 = 1 | ||
| 58 | for _ in 0..<n { result *= 10 } | ||
| 59 | return result | ||
| 60 | } | ||
| 61 | |||
| 62 | /// `math-scale-rounding`: `x` times 10^n, rounding half away from zero when n < 0. | ||
| 63 | static func scaleRounding(_ x: Int128, _ n: Int) throws -> Int128 { | ||
| 64 | if n >= 0 { | ||
| 65 | let (result, overflow) = x.multipliedReportingOverflow(by: try power10(n)) | ||
| 66 | if overflow { throw Unsupported("number size") } | ||
| 67 | return result | ||
| 68 | } | ||
| 69 | if x < 0 { return -(try scaleRounding(-x, n)) } | ||
| 70 | if -n > 38 { return 0 } | ||
| 71 | let truncated = -n - 1 > 37 ? 0 : x / (try power10(-n - 1)) | ||
| 72 | return (truncated + 5) / 10 | ||
| 73 | } | ||
| 74 | |||
| 75 | /// `math-make-float`. | ||
| 76 | static func makeFloat(_ mantissa: Int128, _ exponent: Int) throws -> Value { | ||
| 77 | var m = mantissa | ||
| 78 | var e = exponent | ||
| 79 | if m == 0 { return .float(0, 0) } | ||
| 80 | let excess = digits(m) - precision | ||
| 81 | if excess > 0 { | ||
| 82 | m = try scaleRounding(m, -excess) | ||
| 83 | e += excess | ||
| 84 | } | ||
| 85 | if m == 0 { return .float(0, 0) } | ||
| 86 | while m % 10 == 0 { | ||
| 87 | m /= 10 | ||
| 88 | e += 1 | ||
| 89 | } | ||
| 90 | return .float(Int(m), e) | ||
| 91 | } | ||
| 92 | |||
| 93 | static func parts(_ v: Value) throws -> (Int128, Int) { | ||
| 94 | switch v { | ||
| 95 | case .int(let i): return (Int128(i), 0) | ||
| 96 | case .float(let m, let e): return (Int128(m), e) | ||
| 97 | case .vector: throw Unsupported("vector arithmetic") | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | static func double(_ v: Value) throws -> Double { | ||
| 102 | switch v { | ||
| 103 | case .int(let i): return Double(i) | ||
| 104 | case .float(let m, let e): return Double(m) * pow(10, Double(e)) | ||
| 105 | case .vector: throw Unsupported("vector arithmetic") | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | static func fromDouble(_ d: Double) throws -> Value { | ||
| 110 | guard d.isFinite else { throw Unsupported("infinite result") } | ||
| 111 | if d == 0 { return .float(0, 0) } | ||
| 112 | // Seventeen significant digits are exact for a Double; rounding to 12 follows. | ||
| 113 | let text = String(format: "%.16e", d) | ||
| 114 | let pieces = text.split(separator: "e") | ||
| 115 | let digitsText = pieces[0].replacingOccurrences(of: ".", with: "") | ||
| 116 | guard let mantissa = Int128(digitsText), let exponent = Int(pieces[1]) else { throw Unsupported("number") } | ||
| 117 | return try makeFloat(mantissa, exponent - 16) | ||
| 118 | } | ||
| 119 | |||
| 120 | static func add(_ a: Value, _ b: Value) throws -> Value { | ||
| 121 | if case .int(let x) = a, case .int(let y) = b { | ||
| 122 | let (sum, overflow) = x.addingReportingOverflow(y) | ||
| 123 | if overflow { throw Unsupported("integer size") } | ||
| 124 | return .int(sum) | ||
| 125 | } | ||
| 126 | let (m1, e1) = try parts(a) | ||
| 127 | let (m2, e2) = try parts(b) | ||
| 128 | if m1 == 0 { return try makeFloat(m2, e2) } | ||
| 129 | if m2 == 0 { return try makeFloat(m1, e1) } | ||
| 130 | let ediff = e1 - e2 | ||
| 131 | if ediff >= 0 { | ||
| 132 | if ediff >= 2 * precision { return try makeFloat(m1, e1) } | ||
| 133 | return try makeFloat(try scaleRounding(m1, ediff) + m2, e2) | ||
| 134 | } | ||
| 135 | if -ediff >= 2 * precision { return try makeFloat(m2, e2) } | ||
| 136 | return try makeFloat(m1 + (try scaleRounding(m2, -ediff)), e1) | ||
| 137 | } | ||
| 138 | |||
| 139 | static func negate(_ a: Value) throws -> Value { | ||
| 140 | switch a { | ||
| 141 | case .int(let i): | ||
| 142 | guard i != .min else { throw Unsupported("integer size") } | ||
| 143 | return .int(-i) | ||
| 144 | case .float(let m, let e): return .float(-m, e) | ||
| 145 | case .vector: throw Unsupported("vector arithmetic") | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | static func multiply(_ a: Value, _ b: Value) throws -> Value { | ||
| 150 | if case .int(let x) = a, case .int(let y) = b { | ||
| 151 | let (product, overflow) = x.multipliedReportingOverflow(by: y) | ||
| 152 | if overflow { throw Unsupported("integer size") } | ||
| 153 | return .int(product) | ||
| 154 | } | ||
| 155 | let (m1, e1) = try parts(a) | ||
| 156 | let (m2, e2) = try parts(b) | ||
| 157 | let (product, overflow) = m1.multipliedReportingOverflow(by: m2) | ||
| 158 | if overflow { throw Unsupported("number size") } | ||
| 159 | return try makeFloat(product, e1 + e2) | ||
| 160 | } | ||
| 161 | |||
| 162 | static func divide(_ a: Value, _ b: Value) throws -> Value { | ||
| 163 | if case .int(let x) = a, case .int(let y) = b { | ||
| 164 | guard y != 0 else { throw Unsupported("division by zero") } | ||
| 165 | if x % y == 0 { return .int(x / y) } | ||
| 166 | } | ||
| 167 | let (m1, e1) = try parts(a) | ||
| 168 | let (m2, e2) = try parts(b) | ||
| 169 | guard m2 != 0 else { throw Unsupported("division by zero") } | ||
| 170 | // `math-div-float`: scale so the truncated quotient has one digit more than needed. | ||
| 171 | let ldiff = max(precision + 1 - (digits(m1) - digits(m2)), 0) | ||
| 172 | let quotient = (try scaleRounding(m1, ldiff)) / m2 | ||
| 173 | return try makeFloat(quotient, e1 - ldiff - e2) | ||
| 174 | } | ||
| 175 | |||
| 176 | static func isInteger(_ v: Value) -> Bool { | ||
| 177 | if case .int = v { return true } | ||
| 178 | return false | ||
| 179 | } | ||
| 180 | |||
| 181 | /// `math-ipow`. | ||
| 182 | static func integerPower(_ a: Value, _ n: Int) throws -> Value { | ||
| 183 | if n < 0 { return try integerPower(try divide(.int(1), a), -n) } | ||
| 184 | if n == 0 { return .int(1) } | ||
| 185 | if n == 1 { return a } | ||
| 186 | let square = try multiply(a, a) | ||
| 187 | if n % 2 == 0 { return try integerPower(square, n / 2) } | ||
| 188 | return try multiply(a, try integerPower(square, n / 2)) | ||
| 189 | } | ||
| 190 | |||
| 191 | static func power(_ a: Value, _ b: Value) throws -> Value { | ||
| 192 | if case .int(let n) = b { return try integerPower(a, n) } | ||
| 193 | let base = try double(a) | ||
| 194 | guard base >= 0 else { throw Unsupported("complex result") } | ||
| 195 | return try fromDouble(pow(base, try double(b))) | ||
| 196 | } | ||
| 197 | |||
| 198 | static func compare(_ a: Value, _ b: Value) throws -> Int { | ||
| 199 | let difference = try add(a, try negate(b)) | ||
| 200 | let (m, _) = try parts(difference) | ||
| 201 | return m == 0 ? 0 : (m < 0 ? -1 : 1) | ||
| 202 | } | ||
| 203 | |||
| 204 | // MARK: - Display | ||
| 205 | |||
| 206 | /// `math-format-number`. | ||
| 207 | static func format(_ value: Value, _ floatFormat: FloatFormat) throws -> String { | ||
| 208 | switch value { | ||
| 209 | case .int(let i): return String(i) | ||
| 210 | case .vector: throw Unsupported("vector result") | ||
| 211 | case .float(let m, _) where m < 0: | ||
| 212 | guard case .float(let m, let e) = value else { fatalError() } | ||
| 213 | return "-" + (try format(.float(-m, e), floatFormat)) | ||
| 214 | case .float(let m, var exp): | ||
| 215 | var mant = Int128(m) | ||
| 216 | var figs: Int | ||
| 217 | let kind: String | ||
| 218 | switch floatFormat { | ||
| 219 | case .float(let n): (figs, kind) = (n, "float") | ||
| 220 | case .fix(let n): (figs, kind) = (n, "fix") | ||
| 221 | case .sci(let n): (figs, kind) = (n, "sci") | ||
| 222 | case .eng(let n): (figs, kind) = (n, "eng") | ||
| 223 | } | ||
| 224 | if kind == "fix", figs < 0 || exp + digits(mant) > -figs { | ||
| 225 | if figs < 0 { figs = -figs } | ||
| 226 | mant = try scaleRounding(mant, exp + figs) | ||
| 227 | var str = String(mant) | ||
| 228 | if str.count <= figs { str = String(repeating: "0", count: figs + 1 - str.count) + str } | ||
| 229 | if figs > 0 { | ||
| 230 | return String(str.dropLast(figs)) + "." + String(str.suffix(figs)) | ||
| 231 | } | ||
| 232 | return str + "." | ||
| 233 | } | ||
| 234 | if figs < 0 { figs += precision } | ||
| 235 | if figs > 0 { | ||
| 236 | let adj = figs - digits(mant) | ||
| 237 | if adj < 0 { | ||
| 238 | mant = try scaleRounding(mant, adj) | ||
| 239 | exp -= adj | ||
| 240 | } | ||
| 241 | } | ||
| 242 | var str = String(mant) | ||
| 243 | let len = str.count | ||
| 244 | let dpos = exp + len | ||
| 245 | // `calc-display-sci-high` 0, `calc-display-sci-low` -3. | ||
| 246 | if kind == "float", dpos <= precision, dpos >= -1 { | ||
| 247 | if dpos == 0 { | ||
| 248 | str = "0." + str | ||
| 249 | } else if exp <= 0, dpos > 0 { | ||
| 250 | str = String(str.prefix(dpos)) + "." + String(str.dropFirst(dpos)) | ||
| 251 | } else if exp > 0 { | ||
| 252 | str += String(repeating: "0", count: exp) + "." | ||
| 253 | } else { | ||
| 254 | str = "0." + String(repeating: "0", count: -dpos) + str | ||
| 255 | } | ||
| 256 | return str | ||
| 257 | } | ||
| 258 | let eadj = exp + len | ||
| 259 | let scale = kind == "eng" ? 1 + ((eadj + 300002) % 3) : 1 | ||
| 260 | if scale > str.count { str += String(repeating: "0", count: scale - str.count) } | ||
| 261 | if scale < str.count { str = String(str.prefix(scale)) + "." + String(str.dropFirst(scale)) } | ||
| 262 | return str + "e" + String(eadj - scale) | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | // MARK: - Parsing | ||
| 267 | |||
| 268 | indirect enum Node { | ||
| 269 | case number(Value) | ||
| 270 | case vector([Node]) | ||
| 271 | case unary(Character, Node) | ||
| 272 | case binary(Character, Node, Node) | ||
| 273 | case call(String, [Node]) | ||
| 274 | } | ||
| 275 | |||
| 276 | struct Parser { | ||
| 277 | let chars: [Character] | ||
| 278 | var i = 0 | ||
| 279 | |||
| 280 | init(_ s: String) { chars = Array(s) } | ||
| 281 | |||
| 282 | mutating func skip() { | ||
| 283 | while i < chars.count, chars[i] == " " || chars[i] == "\t" { i += 1 } | ||
| 284 | } | ||
| 285 | |||
| 286 | mutating func peek() -> Character? { | ||
| 287 | skip() | ||
| 288 | return i < chars.count ? chars[i] : nil | ||
| 289 | } | ||
| 290 | |||
| 291 | mutating func parse() throws -> Node { | ||
| 292 | let node = try sum() | ||
| 293 | guard peek() == nil else { throw Unsupported("formula syntax") } | ||
| 294 | return node | ||
| 295 | } | ||
| 296 | |||
| 297 | // `+ -` < `/` < `*` < unary minus < `^`, as Calc's operator table orders them. | ||
| 298 | mutating func sum() throws -> Node { | ||
| 299 | var left = try quotient() | ||
| 300 | while let c = peek(), c == "+" || c == "-" { | ||
| 301 | i += 1 | ||
| 302 | left = .binary(c, left, try quotient()) | ||
| 303 | } | ||
| 304 | return left | ||
| 305 | } | ||
| 306 | |||
| 307 | mutating func quotient() throws -> Node { | ||
| 308 | var left = try product() | ||
| 309 | while peek() == "/" { | ||
| 310 | i += 1 | ||
| 311 | left = .binary("/", left, try product()) | ||
| 312 | } | ||
| 313 | return left | ||
| 314 | } | ||
| 315 | |||
| 316 | mutating func product() throws -> Node { | ||
| 317 | var left = try unary() | ||
| 318 | while peek() == "*" { | ||
| 319 | i += 1 | ||
| 320 | left = .binary("*", left, try unary()) | ||
| 321 | } | ||
| 322 | return left | ||
| 323 | } | ||
| 324 | |||
| 325 | mutating func unary() throws -> Node { | ||
| 326 | if let c = peek(), c == "-" || c == "+" { | ||
| 327 | i += 1 | ||
| 328 | let operand = try unary() | ||
| 329 | return c == "-" ? .unary("-", operand) : operand | ||
| 330 | } | ||
| 331 | return try powerNode() | ||
| 332 | } | ||
| 333 | |||
| 334 | mutating func powerNode() throws -> Node { | ||
| 335 | let base = try primary() | ||
| 336 | if peek() == "^" { | ||
| 337 | i += 1 | ||
| 338 | return .binary("^", base, try unary()) | ||
| 339 | } | ||
| 340 | return base | ||
| 341 | } | ||
| 342 | |||
| 343 | mutating func primary() throws -> Node { | ||
| 344 | guard let c = peek() else { throw Unsupported("formula syntax") } | ||
| 345 | if c == "(" { | ||
| 346 | i += 1 | ||
| 347 | let inner = try sum() | ||
| 348 | guard peek() == ")" else { throw Unsupported("formula syntax") } | ||
| 349 | i += 1 | ||
| 350 | return inner | ||
| 351 | } | ||
| 352 | if c == "[" { | ||
| 353 | i += 1 | ||
| 354 | var items: [Node] = [] | ||
| 355 | if peek() != "]" { | ||
| 356 | items.append(try sum()) | ||
| 357 | while peek() == "," { | ||
| 358 | i += 1 | ||
| 359 | items.append(try sum()) | ||
| 360 | } | ||
| 361 | } | ||
| 362 | guard peek() == "]" else { throw Unsupported("formula syntax") } | ||
| 363 | i += 1 | ||
| 364 | return .vector(items) | ||
| 365 | } | ||
| 366 | if c.isASCII, c.isNumber || c == "." { return .number(try number()) } | ||
| 367 | if c.isLetter { | ||
| 368 | var name = "" | ||
| 369 | while i < chars.count, chars[i].isLetter || chars[i].isNumber || chars[i] == "_" { | ||
| 370 | name.append(chars[i]) | ||
| 371 | i += 1 | ||
| 372 | } | ||
| 373 | guard peek() == "(" else { throw Unsupported("variable \(name)") } | ||
| 374 | i += 1 | ||
| 375 | var args: [Node] = [] | ||
| 376 | if peek() != ")" { | ||
| 377 | args.append(try sum()) | ||
| 378 | while peek() == "," { | ||
| 379 | i += 1 | ||
| 380 | args.append(try sum()) | ||
| 381 | } | ||
| 382 | } | ||
| 383 | guard peek() == ")" else { throw Unsupported("formula syntax") } | ||
| 384 | i += 1 | ||
| 385 | return .call(name, args) | ||
| 386 | } | ||
| 387 | throw Unsupported("formula syntax") | ||
| 388 | } | ||
| 389 | |||
| 390 | /// Calc's number syntax: `7`, `007`, `1.5`, `.5`, `5.`, `1e3`, `1.5e-3`. | ||
| 391 | mutating func number() throws -> Value { | ||
| 392 | var whole = "" | ||
| 393 | while i < chars.count, chars[i].isASCII, chars[i].isNumber { | ||
| 394 | whole.append(chars[i]) | ||
| 395 | i += 1 | ||
| 396 | } | ||
| 397 | var fraction: String? | ||
| 398 | if i < chars.count, chars[i] == "." { | ||
| 399 | i += 1 | ||
| 400 | var f = "" | ||
| 401 | while i < chars.count, chars[i].isASCII, chars[i].isNumber { | ||
| 402 | f.append(chars[i]) | ||
| 403 | i += 1 | ||
| 404 | } | ||
| 405 | fraction = f | ||
| 406 | } | ||
| 407 | var exponent: Int? | ||
| 408 | if i < chars.count, chars[i] == "e" || chars[i] == "E" { | ||
| 409 | var j = i + 1 | ||
| 410 | var e = "" | ||
| 411 | if j < chars.count, chars[j] == "-" || chars[j] == "+" { | ||
| 412 | e.append(chars[j]) | ||
| 413 | j += 1 | ||
| 414 | } | ||
| 415 | var digitsSeen = false | ||
| 416 | while j < chars.count, chars[j].isASCII, chars[j].isNumber { | ||
| 417 | e.append(chars[j]) | ||
| 418 | j += 1 | ||
| 419 | digitsSeen = true | ||
| 420 | } | ||
| 421 | if digitsSeen { | ||
| 422 | exponent = Int(e) | ||
| 423 | i = j | ||
| 424 | } | ||
| 425 | } | ||
| 426 | guard !whole.isEmpty || !(fraction ?? "").isEmpty else { throw Unsupported("formula syntax") } | ||
| 427 | if fraction == nil, exponent == nil { | ||
| 428 | guard let value = Int(whole) else { throw Unsupported("integer size") } | ||
| 429 | return .int(value) | ||
| 430 | } | ||
| 431 | let digitsText = whole + (fraction ?? "") | ||
| 432 | guard let mantissa = Int128(digitsText.isEmpty ? "0" : digitsText) else { throw Unsupported("number size") } | ||
| 433 | return try makeFloat(mantissa, (exponent ?? 0) - (fraction?.count ?? 0)) | ||
| 434 | } | ||
| 435 | } | ||
| 436 | |||
| 437 | // MARK: - Evaluation | ||
| 438 | |||
| 439 | struct Evaluator { | ||
| 440 | let modes: Modes | ||
| 441 | |||
| 442 | func eval(_ node: Node) throws -> Value { | ||
| 443 | switch node { | ||
| 444 | case .number(let v): return v | ||
| 445 | case .vector(let items): return .vector(try items.map(eval)) | ||
| 446 | case .unary(_, let operand): return try negate(try eval(operand)) | ||
| 447 | case .binary(let op, let l, let r): | ||
| 448 | let a = try eval(l) | ||
| 449 | let b = try eval(r) | ||
| 450 | switch op { | ||
| 451 | case "+": return try add(a, b) | ||
| 452 | case "-": return try add(a, try negate(b)) | ||
| 453 | case "*": return try multiply(a, b) | ||
| 454 | case "/": return try divide(a, b) | ||
| 455 | default: return try power(a, b) | ||
| 456 | } | ||
| 457 | case .call(let name, let args): | ||
| 458 | return try call(name, try args.map(eval)) | ||
| 459 | } | ||
| 460 | } | ||
| 461 | |||
| 462 | func elements(_ args: [Value]) throws -> [Value] { | ||
| 463 | guard args.count == 1 else { throw Unsupported("arguments") } | ||
| 464 | if case .vector(let items) = args[0] { return items } | ||
| 465 | return [args[0]] | ||
| 466 | } | ||
| 467 | |||
| 468 | func one(_ args: [Value]) throws -> Value { | ||
| 469 | guard args.count == 1 else { throw Unsupported("arguments") } | ||
| 470 | if case .vector = args[0] { throw Unsupported("vector argument") } | ||
| 471 | return args[0] | ||
| 472 | } | ||
| 473 | |||
| 474 | func radians(_ x: Double) -> Double { modes.degrees ? x * .pi / 180 : x } | ||
| 475 | func angle(_ x: Double) -> Double { modes.degrees ? x * 180 / .pi : x } | ||
| 476 | |||
| 477 | func call(_ name: String, _ args: [Value]) throws -> Value { | ||
| 478 | switch name { | ||
| 479 | case "vsum": | ||
| 480 | return try elements(args).reduce(Value.int(0)) { try add($0, $1) } | ||
| 481 | case "vprod": | ||
| 482 | return try elements(args).reduce(Value.int(1)) { try multiply($0, $1) } | ||
| 483 | case "vcount": | ||
| 484 | return .int(try elements(args).count) | ||
| 485 | case "vmean": | ||
| 486 | let items = try elements(args) | ||
| 487 | guard !items.isEmpty else { throw Unsupported("empty vector") } | ||
| 488 | return try divide(try items.reduce(Value.int(0)) { try add($0, $1) }, .int(items.count)) | ||
| 489 | case "vmax", "vmin": | ||
| 490 | let items = try elements(args) | ||
| 491 | guard var best = items.first else { throw Unsupported("empty vector") } | ||
| 492 | for item in items.dropFirst() { | ||
| 493 | let order = try compare(item, best) | ||
| 494 | if name == "vmax" ? order > 0 : order < 0 { best = item } | ||
| 495 | } | ||
| 496 | return best | ||
| 497 | case "vmedian": | ||
| 498 | var items = try elements(args) | ||
| 499 | guard !items.isEmpty else { throw Unsupported("empty vector") } | ||
| 500 | try items.sort { try compare($0, $1) < 0 } | ||
| 501 | if items.count % 2 == 1 { return items[items.count / 2] } | ||
| 502 | return try divide(try add(items[items.count / 2 - 1], items[items.count / 2]), .int(2)) | ||
| 503 | case "max", "min": | ||
| 504 | guard !args.isEmpty else { throw Unsupported("arguments") } | ||
| 505 | var best = args[0] | ||
| 506 | for item in args.dropFirst() { | ||
| 507 | let order = try compare(item, best) | ||
| 508 | if name == "max" ? order > 0 : order < 0 { best = item } | ||
| 509 | } | ||
| 510 | return best | ||
| 511 | case "abs": | ||
| 512 | let x = try one(args) | ||
| 513 | return try compare(x, .int(0)) < 0 ? try negate(x) : x | ||
| 514 | case "floor", "ceil", "trunc": | ||
| 515 | let x = try one(args) | ||
| 516 | if case .int = x { return x } | ||
| 517 | let d = try double(x) | ||
| 518 | let r = name == "floor" ? d.rounded(.down) : name == "ceil" ? d.rounded(.up) : d.rounded(.towardZero) | ||
| 519 | guard let i = Int(exactly: r) else { throw Unsupported("integer size") } | ||
| 520 | return .int(i) | ||
| 521 | case "round": | ||
| 522 | guard args.count == 1 || args.count == 2 else { throw Unsupported("arguments") } | ||
| 523 | let x = args[0] | ||
| 524 | if args.count == 2 { | ||
| 525 | guard case .int(let places) = args[1] else { throw Unsupported("arguments") } | ||
| 526 | guard case .float(let m, let e) = x else { return x } | ||
| 527 | return try makeFloat(try scaleRounding(Int128(m), e + places), -places) | ||
| 528 | } | ||
| 529 | guard case .float(let m, let e) = x else { return x } | ||
| 530 | guard let i = Int(exactly: try scaleRounding(Int128(m), e)) else { throw Unsupported("integer size") } | ||
| 531 | return .int(i) | ||
| 532 | case "sqrt": | ||
| 533 | let x = try one(args) | ||
| 534 | if case .int(let i) = x { | ||
| 535 | guard i >= 0 else { throw Unsupported("complex result") } | ||
| 536 | let root = Int(Double(i).squareRoot().rounded()) | ||
| 537 | if root * root == i { return .int(root) } | ||
| 538 | } | ||
| 539 | let d = try double(x) | ||
| 540 | guard d >= 0 else { throw Unsupported("complex result") } | ||
| 541 | return try fromDouble(d.squareRoot()) | ||
| 542 | case "exp": | ||
| 543 | let x = try one(args) | ||
| 544 | if x == .int(0) { return .int(1) } | ||
| 545 | return try fromDouble(Foundation.exp(try double(x))) | ||
| 546 | case "ln": | ||
| 547 | let x = try one(args) | ||
| 548 | if x == .int(1) { return .int(0) } | ||
| 549 | let d = try double(x) | ||
| 550 | guard d > 0 else { throw Unsupported("logarithm domain") } | ||
| 551 | return try fromDouble(Foundation.log(d)) | ||
| 552 | case "log10": | ||
| 553 | let x = try one(args) | ||
| 554 | if case .int(let i) = x, i > 0 { | ||
| 555 | var n = i | ||
| 556 | var k = 0 | ||
| 557 | while n % 10 == 0 { | ||
| 558 | n /= 10 | ||
| 559 | k += 1 | ||
| 560 | } | ||
| 561 | if n == 1 { return .int(k) } | ||
| 562 | } | ||
| 563 | let d = try double(x) | ||
| 564 | guard d > 0 else { throw Unsupported("logarithm domain") } | ||
| 565 | return try fromDouble(Foundation.log10(d)) | ||
| 566 | case "sin", "cos", "tan": | ||
| 567 | let x = try one(args) | ||
| 568 | if modes.degrees, case .int(let i) = x, i % 90 == 0 { | ||
| 569 | let quarter = ((i / 90) % 4 + 4) % 4 | ||
| 570 | switch name { | ||
| 571 | case "sin": return .int([0, 1, 0, -1][quarter]) | ||
| 572 | case "cos": return .int([1, 0, -1, 0][quarter]) | ||
| 573 | default: | ||
| 574 | guard quarter % 2 == 0 else { throw Unsupported("infinite result") } | ||
| 575 | return .int(0) | ||
| 576 | } | ||
| 577 | } | ||
| 578 | if !modes.degrees, x == .int(0) { return .int(name == "cos" ? 1 : 0) } | ||
| 579 | let r = radians(try double(x)) | ||
| 580 | return try fromDouble(name == "sin" ? Foundation.sin(r) : name == "cos" ? Foundation.cos(r) : Foundation.tan(r)) | ||
| 581 | case "arcsin", "arccos", "arctan": | ||
| 582 | let x = try one(args) | ||
| 583 | if modes.degrees, case .int(let i) = x { | ||
| 584 | switch (name, i) { | ||
| 585 | case ("arcsin", -1), ("arcsin", 0), ("arcsin", 1): return .int(90 * i) | ||
| 586 | case ("arccos", -1), ("arccos", 0), ("arccos", 1): return .int(90 - 90 * i) | ||
| 587 | case ("arctan", -1), ("arctan", 0), ("arctan", 1): return .int(45 * i) | ||
| 588 | default: break | ||
| 589 | } | ||
| 590 | } | ||
| 591 | let d = try double(x) | ||
| 592 | if name != "arctan", abs(d) > 1 { throw Unsupported("complex result") } | ||
| 593 | let r = name == "arcsin" ? Foundation.asin(d) : name == "arccos" ? Foundation.acos(d) : Foundation.atan(d) | ||
| 594 | return try fromDouble(angle(r)) | ||
| 595 | default: | ||
| 596 | throw Unsupported("function \(name)") | ||
| 597 | } | ||
| 598 | } | ||
| 599 | } | ||
| 600 | } | ||
Sources/OrgCore/Compute/TableFormulas.swift added +607
| @@ -0,0 +1,607 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Table formulas (org-table.el, Org 9.8.7): `org-table-recalculate` and | ||
| 4 | // `org-table-eval-formula` with Calc evaluated natively. Column names, constants, named | ||
| 5 | // fields, marked rows, `remote()`, Lisp formulas, durations and Calc features beyond `Calc` | ||
| 6 | // throw `Calc.Unsupported`; the caller then recalculates the table in Emacs. | ||
| 7 | |||
| 8 | extension EmacsBuffer { | ||
| 9 | struct TableAnalysis { | ||
| 10 | /// Table-relative line index of each data line, from 1 (index 0 unused). | ||
| 11 | var dlines: [Int] | ||
| 12 | /// Hline flag per line, with an extra hline after the last. | ||
| 13 | var types: [Bool] | ||
| 14 | var ncol: Int | ||
| 15 | var begin: Int | ||
| 16 | } | ||
| 17 | |||
| 18 | static let tblfmPattern = "^[ \\t]*#\\+TBLFM: *(.*)" | ||
| 19 | static let rangePattern = "@([-+]?I*[-+]?[0-9]*)(\\$[-+]?[0-9]+)?(\\.\\.@?([-+]?I*[-+]?[0-9]*)(\\$[-+]?[0-9]+)?)?" | ||
| 20 | |||
| 21 | /// `org-table-analyze`. | ||
| 22 | func tableAnalyze() throws -> TableAnalysis { | ||
| 23 | let begin = tableBegin() | ||
| 24 | let end = tableEnd() | ||
| 25 | return try saveExcursion { | ||
| 26 | point = begin | ||
| 27 | var dlines = [0] | ||
| 28 | var types: [Bool] = [] | ||
| 29 | var line = 0 | ||
| 30 | while point < end || (point == end && false) { | ||
| 31 | let content = substring(lineStart(point), lineEnd(point)) | ||
| 32 | guard content.range(of: "^[ \\t]*\\|", options: .regularExpression) != nil else { break } | ||
| 33 | if content.range(of: "^[ \\t]*\\| *[!$_^] *(\\||$)", options: .regularExpression) != nil { | ||
| 34 | throw Calc.Unsupported("named columns, parameters or fields") | ||
| 35 | } | ||
| 36 | let isHline = content.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil | ||
| 37 | types.append(isHline) | ||
| 38 | if !isHline { dlines.append(line) } | ||
| 39 | line += 1 | ||
| 40 | if forwardLine(1) != 0 || point >= end { break } | ||
| 41 | } | ||
| 42 | types.append(true) | ||
| 43 | guard dlines.count > 1 else { throw UserError(message: "No data lines in table") } | ||
| 44 | point = begin | ||
| 45 | forwardLine(dlines[1]) | ||
| 46 | let first = substring(lineStart(point), lineEnd(point)) | ||
| 47 | let ncol = Self.splitFields(first, separator: "[ \\t]*\\|[ \\t]*").count | ||
| 48 | return TableAnalysis(dlines: dlines, types: types, ncol: ncol, begin: begin) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | /// `org-split-string`: leading and trailing separators produce no empty strings. | ||
| 53 | static func splitFields(_ s: String, separator: String) -> [String] { | ||
| 54 | let ns = s as NSString | ||
| 55 | let regex = try! NSRegularExpression(pattern: separator) | ||
| 56 | let matches = regex.matches(in: s, range: NSRange(location: 0, length: ns.length)) | ||
| 57 | guard !matches.isEmpty else { return [s] } | ||
| 58 | var results: [String] = [] | ||
| 59 | var i = 0 | ||
| 60 | for (k, m) in matches.enumerated() { | ||
| 61 | if k == 0, m.range.location == 0 { | ||
| 62 | i = NSMaxRange(m.range) | ||
| 63 | continue | ||
| 64 | } | ||
| 65 | results.append(ns.substring(with: NSRange(location: i, length: m.range.location - i))) | ||
| 66 | i = NSMaxRange(m.range) | ||
| 67 | } | ||
| 68 | if i != ns.length { results.append(ns.substring(from: i)) } | ||
| 69 | return results | ||
| 70 | } | ||
| 71 | |||
| 72 | /// `org-table-get-stored-formulas` from the `#+TBLFM` text `line`. | ||
| 73 | static func storedFormulas(_ line: String) throws -> [(lhs: String, rhs: String)] { | ||
| 74 | var result: [(String, String)] = [] | ||
| 75 | var seen: Set<String> = [] | ||
| 76 | let regex = try! NSRegularExpression(pattern: "^(@[-+I<>0-9.$@]+|\\$([_a-zA-Z0-9]+|[<>]+)) *= *(.*[^ \\t])") | ||
| 77 | for part in splitFields(line, separator: " *:: *") { | ||
| 78 | let ns = part as NSString | ||
| 79 | guard let m = regex.firstMatch(in: part, range: NSRange(location: 0, length: ns.length)) else { continue } | ||
| 80 | let whole = ns.substring(with: m.range(at: 1)) | ||
| 81 | var lhs = whole | ||
| 82 | if m.range(at: 2).location != NSNotFound, | ||
| 83 | whole.range(of: "^\\$([0-9]+|[<>]+)$", options: .regularExpression) == nil { | ||
| 84 | lhs = ns.substring(with: m.range(at: 2)) | ||
| 85 | } | ||
| 86 | guard seen.insert(lhs).inserted else { throw UserError(message: "Double definition `\(lhs)=' in TBLFM line, please fix by hand") } | ||
| 87 | result.append((lhs, ns.substring(with: m.range(at: 3)))) | ||
| 88 | } | ||
| 89 | return result | ||
| 90 | } | ||
| 91 | |||
| 92 | /// The first `#+TBLFM` line after the table, blank lines allowed between. | ||
| 93 | func firstTblfm(after end: Int) -> String? { | ||
| 94 | saveExcursion { | ||
| 95 | point = end | ||
| 96 | while !eobp { | ||
| 97 | let line = substring(lineStart(point), lineEnd(point)) | ||
| 98 | if line.trimmingCharacters(in: .whitespaces).isEmpty { | ||
| 99 | if forwardLine(1) != 0 { return nil } | ||
| 100 | continue | ||
| 101 | } | ||
| 102 | guard let r = line.range(of: Self.tblfmPattern, options: [.regularExpression, .caseInsensitive]) else { return nil } | ||
| 103 | let text = String(line[r]) | ||
| 104 | return String(text[text.index(after: text.firstIndex(of: ":")!)...]).trimmingCharacters(in: .init(charactersIn: " ")) | ||
| 105 | } | ||
| 106 | return nil | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | /// `org-table-formula-handle-first/last-rc`. | ||
| 111 | static func handleFirstLast(_ s: String, analysis: TableAnalysis) throws -> String { | ||
| 112 | var result = s | ||
| 113 | var start = 0 | ||
| 114 | let regex = try! NSRegularExpression(pattern: "([@$])(<+|>+)|(remote\\([^)]+\\))") | ||
| 115 | while let m = regex.firstMatch(in: result, range: NSRange(location: start, length: (result as NSString).length - start)) { | ||
| 116 | let ns = result as NSString | ||
| 117 | if m.range(at: 3).location != NSNotFound { | ||
| 118 | start = NSMaxRange(m.range(at: 3)) | ||
| 119 | continue | ||
| 120 | } | ||
| 121 | let kind = ns.substring(with: m.range(at: 1)) | ||
| 122 | let nmax = kind == "@" ? analysis.dlines.count - 1 : analysis.ncol | ||
| 123 | let marks = ns.substring(with: m.range(at: 2)) | ||
| 124 | let n = marks.first == "<" ? marks.count : nmax - marks.count + 1 | ||
| 125 | if n < 1 || n > nmax { throw UserError(message: "Reference \"\(ns.substring(with: m.range))\" in expression \"\(result)\" points outside table") } | ||
| 126 | start = m.range.location | ||
| 127 | result = ns.replacingCharacters(in: m.range, with: kind + String(n)) | ||
| 128 | } | ||
| 129 | return result | ||
| 130 | } | ||
| 131 | |||
| 132 | /// `org-table--row-type`. | ||
| 133 | static func rowType(hline: Bool, _ n: Int, _ start: Int, backwards: Bool, relative: Bool, analysis: TableAnalysis, desc: String) throws -> Int { | ||
| 134 | var i = start | ||
| 135 | let l = analysis.types.count | ||
| 136 | for _ in 0..<n { | ||
| 137 | while true { | ||
| 138 | i += backwards ? -1 : 1 | ||
| 139 | guard i >= 0, i < l, analysis.types[i] != hline else { break } | ||
| 140 | } | ||
| 141 | } | ||
| 142 | if i < 0 || i >= l { throw UserError(message: "Row descriptor \(desc) leads outside table") } | ||
| 143 | return i == l - 1 ? i - 1 : i | ||
| 144 | } | ||
| 145 | |||
| 146 | /// `org-table--descriptor-line`. | ||
| 147 | static func descriptorLine(_ desc: String, _ cline: Int, analysis: TableAnalysis) throws -> Int { | ||
| 148 | if let n = Int(desc), desc.allSatisfy(\.isNumber) { | ||
| 149 | guard n < analysis.dlines.count else { throw Calc.Unsupported("row out of range") } | ||
| 150 | return analysis.dlines[n] | ||
| 151 | } | ||
| 152 | let ns = desc as NSString | ||
| 153 | let regex = try! NSRegularExpression(pattern: "^(([-+])?(I+))?(([-+])?([0-9]+))?") | ||
| 154 | let m = regex.firstMatch(in: desc, range: NSRange(location: 0, length: ns.length))! | ||
| 155 | func has(_ i: Int) -> Bool { m.range(at: i).location != NSNotFound } | ||
| 156 | if (!has(3) && !has(6)) || (has(3) && has(6) && !has(5)) { throw UserError(message: "Invalid row descriptor `\(desc)'") } | ||
| 157 | var hn = has(3) ? m.range(at: 3).length : nil | ||
| 158 | var hdir = has(2) ? ns.substring(with: m.range(at: 2)) : nil | ||
| 159 | let odir = has(5) ? ns.substring(with: m.range(at: 5)) : nil | ||
| 160 | let on = has(6) ? Int(ns.substring(with: m.range(at: 6))) : nil | ||
| 161 | let rel = has(6) && ((has(1) && !has(3)) || has(5)) | ||
| 162 | var cline = cline | ||
| 163 | if hn != nil, hdir == nil { | ||
| 164 | cline = 0 | ||
| 165 | hdir = "+" | ||
| 166 | if analysis.types[0] { hn! -= 1 } | ||
| 167 | } | ||
| 168 | if hn == nil, on != nil, odir == nil { throw UserError(message: "Should never happen") } | ||
| 169 | if let hn { cline = try rowType(hline: true, hn, cline, backwards: hdir == "-", relative: false, analysis: analysis, desc: desc) } | ||
| 170 | if let on { cline = try rowType(hline: false, on, cline, backwards: odir == "-", relative: rel, analysis: analysis, desc: desc) } | ||
| 171 | return cline | ||
| 172 | } | ||
| 173 | |||
| 174 | /// `org-table-get-field` on the line at point. | ||
| 175 | func tableField(_ column: Int) -> String { | ||
| 176 | saveExcursion { | ||
| 177 | tableGotoColumn(column) | ||
| 178 | skipBackwardNot([124, 10]) | ||
| 179 | if bolp || looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil { return "" } | ||
| 180 | let start = point | ||
| 181 | while let c = character(at: point), c != 124, c != 10, c != 13 { point += 1 } | ||
| 182 | return substring(start, point) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | func isDataLine() -> Bool { | ||
| 187 | looking("^[ \\t]*\\|[^-]", caseInsensitive: false, setsMatch: false) != nil | ||
| 188 | || substring(lineStart(point), lineEnd(point)).range(of: "^[ \\t]*\\|[^-]", options: .regularExpression) != nil | ||
| 189 | } | ||
| 190 | |||
| 191 | func gotoTableLine(_ line: Int, _ analysis: TableAnalysis) { | ||
| 192 | point = analysis.begin | ||
| 193 | forwardLine(line) | ||
| 194 | } | ||
| 195 | |||
| 196 | /// `org-table-get-range`: one field's text, or the fields of a rectangle. | ||
| 197 | func tableRange(_ desc: String, thisline: Int, col: Int, analysis: TableAnalysis) throws -> TableRangeValue { | ||
| 198 | var desc = desc | ||
| 199 | if desc.range(of: "^\\$[0-9]+\\.\\.\\$[0-9]+$", options: .regularExpression) != nil { | ||
| 200 | desc = desc.replacingOccurrences(of: "$", with: "@0$") | ||
| 201 | } | ||
| 202 | let ns = desc as NSString | ||
| 203 | guard let m = try! NSRegularExpression(pattern: Self.rangePattern).firstMatch(in: desc, range: NSRange(location: 0, length: ns.length)) else { | ||
| 204 | throw UserError(message: "Invalid table range specifier `\(desc)'") | ||
| 205 | } | ||
| 206 | func group(_ i: Int) -> String? { m.range(at: i).location == NSNotFound ? nil : ns.substring(with: m.range(at: i)) } | ||
| 207 | let isRange = group(3) != nil | ||
| 208 | func row(_ r: String?) throws -> Int { | ||
| 209 | guard let r, r.contains(where: { !$0.isWhitespace }) else { return thisline } | ||
| 210 | return try Self.descriptorLine(r, thisline, analysis: analysis) | ||
| 211 | } | ||
| 212 | func column(_ c: String?) -> Int { | ||
| 213 | guard let c else { return col } | ||
| 214 | let body = String(c.dropFirst()) | ||
| 215 | let n = Int(body) ?? 0 | ||
| 216 | if n == 0 { return col } | ||
| 217 | return n + (body.first == "-" || body.first == "+" ? col : 0) | ||
| 218 | } | ||
| 219 | let r1 = try row(group(1)) | ||
| 220 | let r2 = try row(group(4)) | ||
| 221 | let c1 = column(group(2)) | ||
| 222 | let c2 = column(group(5)) | ||
| 223 | return try saveExcursion { | ||
| 224 | if !isRange || (r1 == r2 && c1 == c2) { | ||
| 225 | gotoTableLine(r1, analysis) | ||
| 226 | while !isDataLine() { | ||
| 227 | if forwardLine(1) != 0 { throw Calc.Unsupported("reference outside table") } | ||
| 228 | } | ||
| 229 | return .field(tableField(c1).trimmingCharacters(in: .whitespaces)) | ||
| 230 | } | ||
| 231 | var firstRow = min(r1, r2) | ||
| 232 | let lastRow = max(r1, r2) | ||
| 233 | let firstColumn = min(c1, c2) | ||
| 234 | let lastColumn = max(c1, c2) | ||
| 235 | gotoTableLine(firstRow, analysis) | ||
| 236 | while !isDataLine() { | ||
| 237 | if forwardLine(1) != 0 { throw Calc.Unsupported("reference outside table") } | ||
| 238 | firstRow += 1 | ||
| 239 | } | ||
| 240 | let startLine = lineStart(point) | ||
| 241 | forwardLine(lastRow - firstRow) | ||
| 242 | while !isDataLine() { forwardLine(-1) } | ||
| 243 | let endLine = lineStart(point) | ||
| 244 | var fields: [String] = [] | ||
| 245 | point = startLine | ||
| 246 | while point <= endLine { | ||
| 247 | if !atTableHline { | ||
| 248 | for c in firstColumn...lastColumn { fields.append(tableField(c).trimmingCharacters(in: .whitespaces)) } | ||
| 249 | } | ||
| 250 | if forwardLine(1) != 0 { break } | ||
| 251 | } | ||
| 252 | return .list(fields) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | enum TableRangeValue { | ||
| 257 | case field(String) | ||
| 258 | case list([String]) | ||
| 259 | } | ||
| 260 | |||
| 261 | static let calcNumber = "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?$" | ||
| 262 | |||
| 263 | /// `org-table-make-reference` for Calc, refusing what isn't a number. | ||
| 264 | static func reference(_ value: TableRangeValue) throws -> String { | ||
| 265 | func number(_ s: String) throws -> String { | ||
| 266 | guard s.range(of: calcNumber, options: .regularExpression) != nil else { throw Calc.Unsupported("non-numeric field \(s)") } | ||
| 267 | return s | ||
| 268 | } | ||
| 269 | switch value { | ||
| 270 | case .field(let s): | ||
| 271 | return s.contains(where: { !$0.isWhitespace }) ? "(" + (try number(s)) + ")" : "(0)" | ||
| 272 | case .list(let items): | ||
| 273 | return "[" + (try items.filter { $0.contains(where: { !$0.isWhitespace }) }.map(number)).joined(separator: ",") + "]" | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | /// `org-table-eval-formula` with the equation given, at point. | ||
| 278 | func tableEvalFormula(_ formula: String, analysis: TableAnalysis) throws { | ||
| 279 | var formula = formula | ||
| 280 | var modes = Calc.Modes() | ||
| 281 | var fmt: String? | ||
| 282 | if let semicolon = formula.lastIndex(of: ";") { | ||
| 283 | var flags = String(formula[formula.index(after: semicolon)...]) | ||
| 284 | formula = String(formula[..<semicolon]) | ||
| 285 | let numeric = try! NSRegularExpression(pattern: "([pnfse])(-?[0-9]+)") | ||
| 286 | while let m = numeric.firstMatch(in: flags, range: NSRange(location: 0, length: (flags as NSString).length)) { | ||
| 287 | let ns = flags as NSString | ||
| 288 | let n = Int(ns.substring(with: m.range(at: 2)))! | ||
| 289 | switch ns.substring(with: m.range(at: 1)) { | ||
| 290 | case "p": modes.precision = n | ||
| 291 | case "n": modes.format = .float(n) | ||
| 292 | case "f": modes.format = .fix(n) | ||
| 293 | case "s": modes.format = .sci(n) | ||
| 294 | default: modes.format = .eng(n) | ||
| 295 | } | ||
| 296 | flags = ns.replacingCharacters(in: m.range, with: "") | ||
| 297 | } | ||
| 298 | let letters = try! NSRegularExpression(pattern: "[tTUNLEDRFSu]") | ||
| 299 | while let m = letters.firstMatch(in: flags, range: NSRange(location: 0, length: (flags as NSString).length)) { | ||
| 300 | let ns = flags as NSString | ||
| 301 | switch ns.substring(with: m.range) { | ||
| 302 | case "D": modes.degrees = true | ||
| 303 | case "R": modes.degrees = false | ||
| 304 | default: throw Calc.Unsupported("format flag \(ns.substring(with: m.range))") | ||
| 305 | } | ||
| 306 | flags = ns.replacingCharacters(in: m.range, with: "") | ||
| 307 | } | ||
| 308 | if flags.contains(where: { !$0.isWhitespace }) { | ||
| 309 | guard flags.range(of: "^%[-+ #0]*[0-9]*(\\.[0-9]+)?[fgeE]$", options: .regularExpression) != nil else { | ||
| 310 | throw Calc.Unsupported("format \(flags)") | ||
| 311 | } | ||
| 312 | fmt = flags | ||
| 313 | } | ||
| 314 | } | ||
| 315 | if formula.hasPrefix("'(") { throw Calc.Unsupported("Lisp formula") } | ||
| 316 | if formula.range(of: "\\$[a-zA-Z_]", options: .regularExpression) != nil || formula.contains("remote(") | ||
| 317 | || formula.range(of: "[<\\[][0-9]{4}-", options: .regularExpression) != nil || formula.contains("&") { | ||
| 318 | throw Calc.Unsupported("names, constants, remote references or dates") | ||
| 319 | } | ||
| 320 | let line = substring(lineStart(point), lineEnd(point)).trimmingCharacters(in: .whitespaces) | ||
| 321 | let fields = Self.splitFields(line, separator: " *\\| *") | ||
| 322 | let n0 = tableCurrentColumn() | ||
| 323 | let thisline = try saveExcursion { () -> Int in | ||
| 324 | let here = lineStart(point) | ||
| 325 | point = analysis.begin | ||
| 326 | var count = 0 | ||
| 327 | while point < here { | ||
| 328 | if forwardLine(1) != 0 { throw Calc.Unsupported("table position") } | ||
| 329 | count += 1 | ||
| 330 | } | ||
| 331 | return count | ||
| 332 | } | ||
| 333 | var form = formula | ||
| 334 | form = form.replacingOccurrences(of: "@#", with: String(tableCurrentDataLine())) | ||
| 335 | form = form.replacingOccurrences(of: "$#", with: String(n0)) | ||
| 336 | let range = try! NSRegularExpression(pattern: Self.rangePattern) | ||
| 337 | while let m = range.firstMatch(in: form, range: NSRange(location: 0, length: (form as NSString).length)), m.range.length > 1 { | ||
| 338 | let ns = form as NSString | ||
| 339 | let replacement = try Self.reference(try tableRange(ns.substring(with: m.range), thisline: thisline, col: n0, analysis: analysis)) | ||
| 340 | form = ns.replacingCharacters(in: m.range, with: replacement) | ||
| 341 | } | ||
| 342 | let rowRange = try! NSRegularExpression(pattern: "\\$(([-+])?[0-9]+)\\.\\.\\$(([-+])?[0-9]+)") | ||
| 343 | while let m = rowRange.firstMatch(in: form, range: NSRange(location: 0, length: (form as NSString).length)) { | ||
| 344 | let ns = form as NSString | ||
| 345 | let a = Int(ns.substring(with: m.range(at: 1)))! + (m.range(at: 2).location != NSNotFound ? n0 : 0) | ||
| 346 | let b = Int(ns.substring(with: m.range(at: 3)))! + (m.range(at: 4).location != NSNotFound ? n0 : 0) | ||
| 347 | guard a >= 1, b <= fields.count, a - 1 <= b else { throw Calc.Unsupported("range outside row") } | ||
| 348 | form = ns.replacingCharacters(in: m.range, with: try Self.reference(.list(Array(fields[(a - 1)..<b])))) | ||
| 349 | } | ||
| 350 | let single = try! NSRegularExpression(pattern: "\\$(([-+])?[0-9]+)") | ||
| 351 | while let m = single.firstMatch(in: form, range: NSRange(location: 0, length: (form as NSString).length)) { | ||
| 352 | let ns = form as NSString | ||
| 353 | let n = Int(ns.substring(with: m.range(at: 1)))! + (m.range(at: 2).location != NSNotFound ? n0 : 0) | ||
| 354 | let index = (n == 0 ? n0 : max(n, 1)) - 1 | ||
| 355 | guard index < fields.count else { throw UserError(message: "Invalid field specifier \"\(ns.substring(with: m.range))\"") } | ||
| 356 | form = ns.replacingCharacters(in: m.range, with: try Self.reference(.field(fields[index]))) | ||
| 357 | } | ||
| 358 | var result = try Calc.evaluate(form, modes: modes) | ||
| 359 | if let fmt { | ||
| 360 | result = String(format: fmt, Self.stringToNumber(result)) | ||
| 361 | } | ||
| 362 | tableSetField(result) | ||
| 363 | } | ||
| 364 | |||
| 365 | /// `string-to-number`. | ||
| 366 | static func stringToNumber(_ s: String) -> Double { | ||
| 367 | guard let r = s.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 } | ||
| 368 | return Double(s[r]) ?? 0 | ||
| 369 | } | ||
| 370 | |||
| 371 | /// `org-table-justify-field-maybe` while the table needs realigning: the field becomes | ||
| 372 | /// ` NEW |`. | ||
| 373 | func tableSetField(_ value: String) { | ||
| 374 | let pos = point | ||
| 375 | guard tableCurrentColumn() > 0 else { return } | ||
| 376 | skipBackwardNot([124]) | ||
| 377 | let start = point | ||
| 378 | var end = point | ||
| 379 | while let c = character(at: end), c != 124, c != 10 { end += 1 } | ||
| 380 | let closed = character(at: end) == 124 | ||
| 381 | replaceRange(start, closed ? end + 1 : end, with: " \(value) |") | ||
| 382 | point = min(pos, length) | ||
| 383 | } | ||
| 384 | |||
| 385 | func replaceRange(_ start: Int, _ end: Int, with text: String) { | ||
| 386 | let saved = point | ||
| 387 | deleteRegion(start, end) | ||
| 388 | point = start | ||
| 389 | insert(text) | ||
| 390 | point = saved | ||
| 391 | } | ||
| 392 | |||
| 393 | /// `org-table-recalculate`: `all` for every row, otherwise the line at point; `tblfm` | ||
| 394 | /// replaces the stored formulas, as C-c C-c on a `#+TBLFM` line does. | ||
| 395 | func tableRecalculate(all: Bool, tblfm: String? = nil) throws { | ||
| 396 | guard atTable else { throw UserError(message: "Not at a table") } | ||
| 397 | let analysis = try tableAnalyze() | ||
| 398 | let end = tableEnd() | ||
| 399 | guard let line = tblfm ?? firstTblfm(after: end) else { return } | ||
| 400 | let stored = try Self.storedFormulas(line).sorted { $0.lhs < $1.lhs } | ||
| 401 | guard !stored.isEmpty else { return } | ||
| 402 | var columnEquations: [(Int, String)] = [] | ||
| 403 | var fieldEquations: [(String, String)] = [] | ||
| 404 | for (oldLHS, rawRHS) in stored { | ||
| 405 | let rhs = try Self.handleFirstLast(rawRHS, analysis: analysis) | ||
| 406 | if oldLHS.range(of: "^@-?I+", options: .regularExpression) != nil { throw UserError(message: "Can't assign to hline relative reference") } | ||
| 407 | let lhs = try Self.handleFirstLast(oldLHS, analysis: analysis) | ||
| 408 | if lhs.range(of: "^\\$[0-9]+$", options: .regularExpression) != nil { | ||
| 409 | columnEquations.append((Int(lhs.dropFirst())!, rhs)) | ||
| 410 | } else { | ||
| 411 | fieldEquations.append((lhs, rhs)) | ||
| 412 | } | ||
| 413 | } | ||
| 414 | // `org-table-expand-lhs-ranges`. | ||
| 415 | var fields: [(row: Int, column: Int, rhs: String)] = [] | ||
| 416 | for (lhs, rhs) in fieldEquations { | ||
| 417 | if let m = lhs.firstMatch(of: /^@([0-9]+)\$([0-9]+)$/) { | ||
| 418 | fields.append((Int(m.1)!, Int(m.2)!, rhs)) | ||
| 419 | } else if lhs.range(of: "^@[-+0-9]+\\$-?[0-9]+$", options: .regularExpression) != nil || lhs.range(of: "^[a-zA-Z]", options: .regularExpression) != nil { | ||
| 420 | throw Calc.Unsupported("relative or named field formula") | ||
| 421 | } else if let m = lhs.firstMatch(of: /^@([0-9]+)$/) { | ||
| 422 | for c in 1...analysis.ncol { fields.append((Int(m.1)!, c, rhs)) } | ||
| 423 | } else { | ||
| 424 | let corners = try rangeCorners(lhs, analysis: analysis) | ||
| 425 | guard let r1 = Self.lineToDline(corners.r1, analysis), let r2 = Self.lineToDline(corners.r2, analysis, above: true), r1 <= r2 else { | ||
| 426 | throw Calc.Unsupported("formula range") | ||
| 427 | } | ||
| 428 | for r in r1...r2 { for c in corners.c1...corners.c2 { fields.append((r, c, rhs)) } } | ||
| 429 | } | ||
| 430 | } | ||
| 431 | let savedLine = lineStart(point) | ||
| 432 | let savedColumn = tableCurrentColumn() | ||
| 433 | let lineMarker = marker(savedLine) | ||
| 434 | defer { release(lineMarker) } | ||
| 435 | |||
| 436 | let endMarker = marker(end) | ||
| 437 | defer { release(endMarker) } | ||
| 438 | var beg: Int | ||
| 439 | if all { | ||
| 440 | beg = analysis.begin | ||
| 441 | point = beg | ||
| 442 | if searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) != nil, | ||
| 443 | searchForward("^[ \\t]*\\|-", bound: end, caseInsensitive: false) != nil, | ||
| 444 | let body = searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) { | ||
| 445 | beg = body[0]!.lowerBound | ||
| 446 | } | ||
| 447 | } else { | ||
| 448 | beg = lineStart(point) | ||
| 449 | endMarker.position = min(length, lineEnd(point) + 1) | ||
| 450 | } | ||
| 451 | |||
| 452 | let currentLine = savedLine - 0 | ||
| 453 | var seen: Set<String> = [] | ||
| 454 | var untouchable: Set<String> = [] | ||
| 455 | for field in fields { | ||
| 456 | let reference = "@\(field.row)$\(field.column)" | ||
| 457 | guard seen.insert(reference).inserted else { throw UserError(message: "Several field/range formulas try to set \(reference)") } | ||
| 458 | guard field.row < analysis.dlines.count else { throw UserError(message: "Invalid row number in \(reference)") } | ||
| 459 | let lineStartOffset = saveExcursion { () -> Int in | ||
| 460 | gotoTableLine(analysis.dlines[field.row], analysis) | ||
| 461 | return lineStart(point) | ||
| 462 | } | ||
| 463 | if all || lineStartOffset == currentLine { untouchable.insert(reference) } | ||
| 464 | } | ||
| 465 | |||
| 466 | point = beg | ||
| 467 | let rowMarker = marker(beg) | ||
| 468 | defer { release(rowMarker) } | ||
| 469 | while let match = searchForward("^[ \\t]*\\|[^-]", bound: endMarker.position, caseInsensitive: false) { | ||
| 470 | _ = match | ||
| 471 | let rowStart = lineStart(point) | ||
| 472 | rowMarker.position = rowStart | ||
| 473 | let first = saveExcursion { () -> String in | ||
| 474 | point = rowStart | ||
| 475 | return tableField(1) | ||
| 476 | } | ||
| 477 | if first.range(of: "^ *[_^!$/] *$", options: .regularExpression) == nil { | ||
| 478 | let dline = saveExcursion { () -> Int in | ||
| 479 | point = rowStart | ||
| 480 | return tableCurrentDataLine() | ||
| 481 | } | ||
| 482 | for (column, rhs) in columnEquations { | ||
| 483 | point = rowMarker.position | ||
| 484 | tableGotoColumn(column, force: true) | ||
| 485 | if untouchable.contains("@\(dline)$\(column)") { continue } | ||
| 486 | try tableEvalFormula(rhs, analysis: analysis) | ||
| 487 | } | ||
| 488 | } | ||
| 489 | point = rowMarker.position | ||
| 490 | if forwardLine(1) != 0 || point >= endMarker.position { break } | ||
| 491 | } | ||
| 492 | for field in fields { | ||
| 493 | gotoTableLine(analysis.dlines[field.row], analysis) | ||
| 494 | let count = substring(lineStart(point), lineEnd(point)).filter { $0 == "|" }.count - 1 | ||
| 495 | if field.column > count { throw UserError(message: "Missing columns in the table. Aborting") } | ||
| 496 | tableGotoColumn(field.column) | ||
| 497 | try tableEvalFormula(field.rhs, analysis: analysis) | ||
| 498 | } | ||
| 499 | tableAlign() | ||
| 500 | point = lineMarker.position | ||
| 501 | tableGotoColumn(savedColumn) | ||
| 502 | } | ||
| 503 | |||
| 504 | /// `org-table-get-range` with `corners-only`. | ||
| 505 | func rangeCorners(_ desc: String, analysis: TableAnalysis) throws -> (r1: Int, c1: Int, r2: Int, c2: Int) { | ||
| 506 | let ns = desc as NSString | ||
| 507 | guard let m = try! NSRegularExpression(pattern: Self.rangePattern).firstMatch(in: desc, range: NSRange(location: 0, length: ns.length)) else { | ||
| 508 | throw UserError(message: "Invalid table range specifier `\(desc)'") | ||
| 509 | } | ||
| 510 | func group(_ i: Int) -> String? { m.range(at: i).location == NSNotFound ? nil : ns.substring(with: m.range(at: i)) } | ||
| 511 | let thisline = 0 | ||
| 512 | func row(_ r: String?) throws -> Int { | ||
| 513 | guard let r, !r.isEmpty else { return thisline } | ||
| 514 | return try Self.descriptorLine(r, thisline, analysis: analysis) | ||
| 515 | } | ||
| 516 | func column(_ c: String?) -> Int { | ||
| 517 | guard let c, let n = Int(c.dropFirst()), n != 0 else { return 1 } | ||
| 518 | return n + (c.dropFirst().first == "-" || c.dropFirst().first == "+" ? 1 : 0) | ||
| 519 | } | ||
| 520 | let r1 = try row(group(1)) | ||
| 521 | let r2 = try row(group(4)) | ||
| 522 | let c1 = column(group(2)) | ||
| 523 | let c2 = column(group(5)) | ||
| 524 | return (min(r1, r2), min(c1, c2), max(r1, r2), max(c1, c2)) | ||
| 525 | } | ||
| 526 | |||
| 527 | /// `org-table-line-to-dline`. | ||
| 528 | static func lineToDline(_ line: Int, _ analysis: TableAnalysis, above: Bool = false) -> Int? { | ||
| 529 | let dlines = analysis.dlines | ||
| 530 | var low = 1 | ||
| 531 | var high = dlines.count - 1 | ||
| 532 | if dlines[low] > line || dlines[high] < line { return nil } | ||
| 533 | if line == dlines[high] { return high } | ||
| 534 | while high - low > 1 { | ||
| 535 | let mean = (high + low) / 2 | ||
| 536 | if dlines[mean] == line { return mean } | ||
| 537 | if dlines[mean] > line { high = mean } else { low = mean } | ||
| 538 | } | ||
| 539 | if line == dlines[high] { return high } | ||
| 540 | if line == dlines[low] { return low } | ||
| 541 | return above ? low : high | ||
| 542 | } | ||
| 543 | } | ||
| 544 | |||
| 545 | /// C-c * and C-u C-c *: `org-table-recalculate`, and C-c C-c on a `#+TBLFM` line. Tables the | ||
| 546 | /// native evaluator can't do fail with `TableRecalculate.emacsMessage`, and the app | ||
| 547 | /// recalculates them in Emacs. | ||
| 548 | public struct TableRecalculate: OrgCommand { | ||
| 549 | public let all: Bool | ||
| 550 | public init(all: Bool) { self.all = all } | ||
| 551 | public var id: String { all ? "org.table.recalc-all" : "org.table.recalc" } | ||
| 552 | public var title: String { all ? "Recalculate Table" : "Recalculate Table Row" } | ||
| 553 | |||
| 554 | public static let emacsMessage = "needs Emacs: " | ||
| 555 | |||
| 556 | public func applies(in context: EditContext) -> Bool { | ||
| 557 | KeyContext.holds("table", in: context) || Self.tblfmLine(in: context) != nil | ||
| 558 | } | ||
| 559 | |||
| 560 | static func tblfmLine(in context: EditContext) -> String? { | ||
| 561 | let buffer = EmacsBuffer(context.text, point: context.caret) | ||
| 562 | let line = buffer.substring(buffer.lineStart(buffer.point), buffer.lineEnd(buffer.point)) | ||
| 563 | guard line.range(of: EmacsBuffer.tblfmPattern, options: [.regularExpression, .caseInsensitive]) != nil else { return nil } | ||
| 564 | return line | ||
| 565 | } | ||
| 566 | |||
| 567 | public func run(in context: EditContext) -> CommandStep { | ||
| 568 | let step = commitBuffer(context) { buffer in | ||
| 569 | do { | ||
| 570 | if let line = Self.tblfmLine(in: context) { | ||
| 571 | // `org-table-calc-current-TBLFM`: that line's formulas, every row, with | ||
| 572 | // point at the end of the table. | ||
| 573 | let formulas = String(line[line.index(after: line.firstIndex(of: ":")!)...]).trimmingCharacters(in: .init(charactersIn: " ")) | ||
| 574 | let saved = buffer.marker(buffer.point) | ||
| 575 | defer { buffer.release(saved) } | ||
| 576 | guard let tableEnd = buffer.saveExcursion({ () -> Int? in | ||
| 577 | while buffer.forwardLine(-1) == 0 { | ||
| 578 | if buffer.lookingAtLine("^[ \\t]*\\|") { return buffer.lineEnd(buffer.point) } | ||
| 579 | } | ||
| 580 | return nil | ||
| 581 | }) else { throw EmacsBuffer.UserError(message: "Not at a table") } | ||
| 582 | buffer.point = tableEnd | ||
| 583 | try buffer.tableRecalculate(all: true, tblfm: formulas) | ||
| 584 | buffer.point = saved.position | ||
| 585 | } else { | ||
| 586 | try buffer.tableRecalculate(all: all) | ||
| 587 | } | ||
| 588 | } catch let error as Calc.Unsupported { | ||
| 589 | throw EmacsBuffer.UserError(message: Self.emacsMessage + error.reason) | ||
| 590 | } | ||
| 591 | } | ||
| 592 | guard case .failed(let message) = step, message.hasPrefix(Self.emacsMessage) else { return step } | ||
| 593 | // Emacs recalculates the table; its text replaces the table here. | ||
| 594 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) | ||
| 595 | let onTblfm = Self.tblfmLine(in: context) != nil | ||
| 596 | if onTblfm { | ||
| 597 | while buffer.forwardLine(-1) == 0, !buffer.lookingAtLine("^[ \\t]*\\|") {} | ||
| 598 | } | ||
| 599 | let table = buffer.tableBegin()..<buffer.tableEnd() | ||
| 600 | let tblfm = onTblfm ? Self.tblfmLine(in: context) ?? "" : buffer.firstTblfm(after: table.upperBound) ?? "" | ||
| 601 | let form = onTblfm ? "(org-table-calc-current-TBLFM)" : all ? "(org-table-recalculate t)" : "(org-table-recalculate nil)" | ||
| 602 | return .external(.emacsTable( | ||
| 603 | table: table, point: context.caret, form: form, | ||
| 604 | reason: String(message.dropFirst(Self.emacsMessage.count)), lisp: tblfm.contains("'(") | ||
| 605 | )) | ||
| 606 | } | ||
| 607 | } | ||
Sources/OrgCore/Keymap/Keymap.swift +2 −1
| @@ -103,7 +103,7 @@ public struct Keymap: Sendable, Equatable { | |||
| 103 | 103 | ||
| 104 | /// Context names a binding's `when` can use. | 104 | /// Context names a binding's `when` can use. |
| 105 | public enum KeyContext { | 105 | public enum KeyContext { |
| 106 | public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp"] | 106 | public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm"] |
| 107 | 107 | ||
| 108 | public static func holds(_ name: String, in context: EditContext) -> Bool { | 108 | public static func holds(_ name: String, in context: EditContext) -> Bool { |
| 109 | switch name { | 109 | switch name { |
| @@ -112,6 +112,7 @@ public enum KeyContext { | |||
| 112 | case "item": return enclosing(.item, context) != nil | 112 | case "item": return enclosing(.item, context) != nil |
| 113 | case "region": return context.selection.contains { !$0.isEmpty } | 113 | case "region": return context.selection.contains { !$0.isEmpty } |
| 114 | case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil | 114 | case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil |
| 115 | case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil | ||
| 115 | default: return false | 116 | default: return false |
| 116 | } | 117 | } |
| 117 | } | 118 | } |
Sources/OrgCore/Keymap/Presets.swift +8
| @@ -289,6 +289,14 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 289 | command = "org.table.align" | 289 | command = "org.table.align" |
| 290 | when = "table" | 290 | when = "table" |
| 291 | [[bind]] | 291 | [[bind]] |
| 292 | keys = "C-c C-c" | ||
| 293 | command = "org.table.recalc-all" | ||
| 294 | when = "tblfm" | ||
| 295 | [[bind]] | ||
| 296 | keys = "C-c *" | ||
| 297 | command = "org.table.recalc" | ||
| 298 | when = "table" | ||
| 299 | [[bind]] | ||
| 292 | keys = "M-<up>" | 300 | keys = "M-<up>" |
| 293 | command = "org.table.row-up" | 301 | command = "org.table.row-up" |
| 294 | when = "table" | 302 | when = "table" |
Sources/OrgEditorAppKit/OrgEditor.swift +4
| @@ -184,6 +184,8 @@ public final class OrgEditor: NSObject { | |||
| 184 | public var onAppCommand: ((String) -> Void)? | 184 | public var onAppCommand: ((String) -> Void)? |
| 185 | /// Called when a command asks a question; reply with the answer, or nil to cancel. | 185 | /// Called when a command asks a question; reply with the answer, or nil to cancel. |
| 186 | public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? | 186 | public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? |
| 187 | /// Runs work outside the text, such as a table recalculation in Emacs. | ||
| 188 | public var onExternal: ((ExternalRequest) -> Void)? | ||
| 187 | 189 | ||
| 188 | public var keymap: Keymap { | 190 | public var keymap: Keymap { |
| 189 | get { dispatcher.keymap } | 191 | get { dispatcher.keymap } |
| @@ -324,6 +326,8 @@ public final class OrgEditor: NSObject { | |||
| 324 | for case .message(let text) in result.effects { onMessage?(text) } | 326 | for case .message(let text) in result.effects { onMessage?(text) } |
| 325 | case .failed(let message): | 327 | case .failed(let message): |
| 326 | onMessage?(message) | 328 | onMessage?(message) |
| 329 | case .external(let request): | ||
| 330 | if let onExternal { onExternal(request) } else { onMessage?("This needs Emacs, which isn't available here.") } | ||
| 327 | case .prompt(let prompt): | 331 | case .prompt(let prompt): |
| 328 | onPrompt?(prompt) { [weak self] answer in | 332 | onPrompt?(prompt) { [weak self] answer in |
| 329 | guard let self else { return } | 333 | guard let self else { return } |
Sources/Orgstar/AgendaView.swift +2
| @@ -297,6 +297,8 @@ struct AgendaView: View { | |||
| 297 | case .failed(let text): | 297 | case .failed(let text): |
| 298 | message = text | 298 | message = text |
| 299 | tick += 1 | 299 | tick += 1 |
| 300 | case .external: | ||
| 301 | message = "Not available from the agenda" | ||
| 300 | case .commit: | 302 | case .commit: |
| 301 | message = nil | 303 | message = nil |
| 302 | tick += 1 | 304 | tick += 1 |
Sources/Orgstar/ContentView.swift +25 −1
| @@ -13,6 +13,7 @@ struct ContentView: View { | |||
| 13 | @AppStorage("reminderLead") private var reminderLead = 12 | 13 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 14 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | 14 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 15 | @State private var reminderAgenda: AgendaModel? | 15 | @State private var reminderAgenda: AgendaModel? |
| 16 | @State private var trust = TrustStore() | ||
| 16 | @State private var hour = 0 | 17 | @State private var hour = 0 |
| 17 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 18 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 18 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | 19 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| @@ -56,7 +57,7 @@ struct ContentView: View { | |||
| 56 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) | 57 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 57 | } detail: { | 58 | } detail: { |
| 58 | if session.path != nil { | 59 | if session.path != nil { |
| 59 | EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform) | 60 | EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) |
| 60 | .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) } | 61 | .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) } |
| 61 | } else { | 62 | } else { |
| 62 | ContentUnavailableView( | 63 | ContentUnavailableView( |
| @@ -165,6 +166,29 @@ struct ContentView: View { | |||
| 165 | } | 166 | } |
| 166 | } | 167 | } |
| 167 | 168 | ||
| 169 | /// Work a command hands to the platform: tables recalculated in Emacs. | ||
| 170 | private func runExternal(_ request: ExternalRequest) { | ||
| 171 | switch request { | ||
| 172 | case .emacsTable(let table, let point, let form, let reason, let lisp): | ||
| 173 | let run = { | ||
| 174 | commands.show("Recalculating in Emacs (\(reason))…") | ||
| 175 | Task { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) } | ||
| 176 | } | ||
| 177 | guard lisp, let path = session.path, let text = session.document?.text else { return run() } | ||
| 178 | let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound)) | ||
| 179 | if trust.isTrusted(path: path, content: content) { return run() } | ||
| 180 | commands.ask(Prompt(key: "confirm", message: "This table's formulas run Lisp. Run them? (yes, no, always)", choices: ["yes", "no", "always"])) { answer in | ||
| 181 | switch answer { | ||
| 182 | case "always": | ||
| 183 | trust.trust(path: path, content: content) | ||
| 184 | run() | ||
| 185 | case "yes": run() | ||
| 186 | default: commands.show("Not recalculated") | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 168 | private struct ReminderKey: Hashable { | 192 | private struct ReminderKey: Hashable { |
| 169 | var enabled: Bool | 193 | var enabled: Bool |
| 170 | var lead: Int | 194 | var lead: Int |
Sources/Orgstar/EditorView.swift +2
| @@ -17,6 +17,7 @@ struct EditorView: NSViewRepresentable { | |||
| 17 | let optionAsMeta: OptionAsMeta | 17 | let optionAsMeta: OptionAsMeta |
| 18 | let options: EditingOptions | 18 | let options: EditingOptions |
| 19 | let onAppCommand: (String) -> Void | 19 | let onAppCommand: (String) -> Void |
| 20 | let onExternal: (ExternalRequest) -> Void | ||
| 20 | 21 | ||
| 21 | final class Coordinator { | 22 | final class Coordinator { |
| 22 | var editor: OrgEditor? | 23 | var editor: OrgEditor? |
| @@ -45,6 +46,7 @@ struct EditorView: NSViewRepresentable { | |||
| 45 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } | 46 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } |
| 46 | } | 47 | } |
| 47 | editor.onAppCommand = onAppCommand | 48 | editor.onAppCommand = onAppCommand |
| 49 | editor.onExternal = onExternal | ||
| 48 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } | 50 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
| 49 | commands.editor = editor | 51 | commands.editor = editor |
| 50 | let scroll = editor.makeScrollView() | 52 | let scroll = editor.makeScrollView() |
Tests/OrgAppTests/AppTests.swift +21
| @@ -420,3 +420,24 @@ struct ReminderSchedulerTests { | |||
| 420 | #expect(center.ids == ["someone.else"]) | 420 | #expect(center.ids == ["someone.else"]) |
| 421 | } | 421 | } |
| 422 | } | 422 | } |
| 423 | |||
| 424 | @MainActor | ||
| 425 | struct EmacsFallbackTests { | ||
| 426 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 427 | func recalculatesTablesNativeCodeCannot() async throws { | ||
| 428 | let notes = try Folder() | ||
| 429 | let file = try notes.write("t.org", "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | |\n| y | 2 | |\n#+TBLFM: $3=$2*2\n") | ||
| 430 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 431 | session.open(file.path) | ||
| 432 | let step = session.perform(TableRecalculate(all: true), path: file.path, at: 50, expect: nil) | ||
| 433 | guard case .external(.emacsTable(let table, let point, let form, let reason, let lisp)) = step else { | ||
| 434 | Issue.record("\(step)") | ||
| 435 | return | ||
| 436 | } | ||
| 437 | #expect(reason == "non-numeric field a") | ||
| 438 | #expect(!lisp) | ||
| 439 | let message = await session.recalculateInEmacs(table: table, point: point, form: form) | ||
| 440 | #expect(message == "Recalculated in Emacs") | ||
| 441 | #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n") | ||
| 442 | } | ||
| 443 | } | ||
Tests/OrgCoreTests/CalcTests.swift added +105
| @@ -0,0 +1,105 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// Runs `calc-eval` with org's table modes over a list of expressions. | ||
| 6 | enum CalcOracle { | ||
| 7 | static func run(_ cases: [(expression: String, format: String)]) throws -> [String] { | ||
| 8 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-calc-\(UUID().uuidString)") | ||
| 9 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 10 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 11 | let input = folder.appendingPathComponent("input.json") | ||
| 12 | let output = folder.appendingPathComponent("output.json") | ||
| 13 | let script = folder.appendingPathComponent("calc.el") | ||
| 14 | try JSONSerialization.data(withJSONObject: cases.map { [$0.expression, $0.format] }).write(to: input) | ||
| 15 | try #""" | ||
| 16 | ;;; -*- lexical-binding: t -*- | ||
| 17 | (require 'calc) | ||
| 18 | (require 'org-table) | ||
| 19 | (require 'json) | ||
| 20 | (let* ((cases (with-temp-buffer | ||
| 21 | (insert-file-contents (getenv "ORACLE_INPUT")) | ||
| 22 | (json-parse-buffer :array-type 'list))) | ||
| 23 | (results | ||
| 24 | (mapcar (lambda (c) | ||
| 25 | (let* ((modes (copy-sequence org-calc-default-modes)) | ||
| 26 | (format (car (read-from-string (nth 1 c))))) | ||
| 27 | (setq modes (plist-put modes 'calc-float-format format)) | ||
| 28 | (let ((r (calc-eval (cons (car c) modes) nil))) | ||
| 29 | (if (stringp r) r (format "ERROR %S" r))))) | ||
| 30 | cases))) | ||
| 31 | (with-temp-file (getenv "ORACLE_OUTPUT") | ||
| 32 | (insert (json-encode (vconcat results))))) | ||
| 33 | """#.write(to: script, atomically: true, encoding: .utf8) | ||
| 34 | _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path]) | ||
| 35 | return try JSONDecoder().decode([String].self, from: Data(contentsOf: output)) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | struct CalcTests { | ||
| 40 | static let fixed = [ | ||
| 41 | "1+2", "7/2", "6/3", "1/3", "2/3", "(2)^(0.5)", "sqrt(2)", "sqrt(4)", "sqrt(16.0)", "1.5*2", "3.0", "(3.0)", "2^-1", | ||
| 42 | "10/4", "1e5", "1e20*1", "123456789*1000", "0.1+0.2", "1/7", "100/7", "1000000/7", "1e10/7", "1e-5/7", "0.001/7", | ||
| 43 | "0.0001/3", "(1.23456789012345)", "vsum([1,2,3.5])", "vmean([1,2])", "vmean([1,2,4])", "vmax([1,5,3])", "vsum([])", | ||
| 44 | "vcount([1,2])", "vmedian([1,3,2,4])", "sin(30)", "cos(60)", "tan(45)", "exp(1)", "ln(10)", "log10(1000)", "log10(2)", | ||
| 45 | "-3", "(-3)*2", "--3", "(+3)", "2*(0)", "2^0.5*2^0.5", "99999999.5*1", "123456785*0.1", "0.5*1e-10", "1.5e3", | ||
| 46 | "12345678.9*1", "123456789.1*1", "1.25*1", "0.000123*1", "1e100*1e100", "round(2.5)", "floor(2.7)", "abs(-3)", | ||
| 47 | "max(1,2)", "1.05*3", "2.675*1", "8/2*2", "8/2/2", "2*3/4", "-2^2", "2^3^2", "2^-2", "(-2)^2", "sin(0)", "sin(90)", | ||
| 48 | "cos(0)", "cos(90)", "tan(0)", "exp(0)", "ln(1)", "log10(1)", "log10(100.0)", "sqrt(2.25)", "sqrt(0)", "arcsin(1)", | ||
| 49 | "arctan(1)", "arccos(0.5)", "1.5^2", "1.1^10", "0.1^3", "2.5*4", "10/2.5", "1/3*3", "1.0/3", "vmedian([1,3])", | ||
| 50 | "vmedian([1,2,3])", "vmedian([1.5,2])", "vmean([1.5,2.5])", "vsum([0.1,0.2,0.3])", "vmax([1,2.5])", "vmin([3,1.0])", | ||
| 51 | "round(-2.5)", "round(2.4)", "round(1.23456,2)", "floor(-2.5)", "ceil(2.1)", "trunc(-2.7)", "abs(-1.5)", "max(1,2.5)", | ||
| 52 | "min(4,2)", "1e3", "1.e3", "007", "(0.5)", "(.5)", "(5.)", "123456789012*10", "1.23456789012345678", "exp(10)", | ||
| 53 | "exp(100)", "ln(0.5)", "sqrt(1e-10)", "2.0^2", "0^0", "1-1.0", "0.0", "-0.0", "1.0-1.0", "(-1.5)*(-2)", | ||
| 54 | "(12)+(3.5)*(2)", "vsum([12,3.5,7])/3", "(1.5)/(0.5)", "(100)*(1.07)^(5)", "(19.99)*(3)", "(0.07)*(19.99)", | ||
| 55 | "vmean([10,20,25])", "(5)-(7.25)", "(-0.001)*(1000)", "1/(3)", "(2)/(3)*(3)", | ||
| 56 | ] | ||
| 57 | |||
| 58 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 59 | func matchesCalc() throws { | ||
| 60 | var cases = Self.fixed.map { ($0, "(float 8)") } | ||
| 61 | for format in ["(float 3)", "(fix 2)", "(fix 0)", "(sci 3)", "(eng 2)"] { | ||
| 62 | for e in ["2/3", "3", "1.5", "1234.5678", "0.000123", "1e20*1", "-2/3", "1/7*100"] { cases.append((e, format)) } | ||
| 63 | } | ||
| 64 | // Generated: random operands and operators. | ||
| 65 | var generator = SystemRandomNumberGenerator() | ||
| 66 | let numbers = ["1", "2", "3", "7", "10", "0.5", "1.25", "3.14159", "100", "0.001", "12.5", "99", "0.3", "1e3", "2.5e-2"] | ||
| 67 | let ops = ["+", "-", "*", "/"] | ||
| 68 | for _ in 0..<300 { | ||
| 69 | var e = numbers.randomElement(using: &generator)! | ||
| 70 | for _ in 0..<Int.random(in: 1...4, using: &generator) { | ||
| 71 | e = "(\(e))\(ops.randomElement(using: &generator)!)(\(numbers.randomElement(using: &generator)!))" | ||
| 72 | } | ||
| 73 | cases.append((e, "(float 8)")) | ||
| 74 | } | ||
| 75 | let emacs = try CalcOracle.run(cases) | ||
| 76 | #expect(emacs.count == cases.count) | ||
| 77 | var skipped: [String] = [] | ||
| 78 | for (c, expected) in zip(cases, emacs) { | ||
| 79 | var modes = Calc.Modes() | ||
| 80 | let f = c.1.dropFirst().dropLast().split(separator: " ") | ||
| 81 | let n = Int(f[1])! | ||
| 82 | switch f[0] { | ||
| 83 | case "fix": modes.format = .fix(n) | ||
| 84 | case "sci": modes.format = .sci(n) | ||
| 85 | case "eng": modes.format = .eng(n) | ||
| 86 | default: modes.format = .float(n) | ||
| 87 | } | ||
| 88 | let ours: String | ||
| 89 | do { ours = try Calc.evaluate(c.0, modes: modes) } catch { ours = "unsupported: \(error)" } | ||
| 90 | if ours.hasPrefix("unsupported") { | ||
| 91 | skipped.append(c.0) | ||
| 92 | continue | ||
| 93 | } | ||
| 94 | #expect(ours == expected, "\(c.0) \(c.1)") | ||
| 95 | } | ||
| 96 | // Only what Calc itself can't show as a number. | ||
| 97 | #expect(skipped == [], "\(skipped)") | ||
| 98 | } | ||
| 99 | |||
| 100 | @Test func unsupported() { | ||
| 101 | for e in ["abc*2", "1/0", "vmean([])", "pi", "5%", "(-8)^(1/3)", "2 3"] { | ||
| 102 | #expect(throws: Calc.Unsupported.self, "\(e)") { try Calc.evaluate(e) } | ||
| 103 | } | ||
| 104 | } | ||
| 105 | } | ||
Tests/OrgCoreTests/EmacsOracle.swift +2
| @@ -144,5 +144,7 @@ func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: | |||
| 144 | return (text, caret, message) | 144 | return (text, caret, message) |
| 145 | case .prompt(let prompt): | 145 | case .prompt(let prompt): |
| 146 | return (text, caret, "prompt: \(prompt.key)") | 146 | return (text, caret, "prompt: \(prompt.key)") |
| 147 | case .external(let request): | ||
| 148 | return (text, caret, "external: \(request)") | ||
| 147 | } | 149 | } |
| 148 | } | 150 | } |
Tests/OrgCoreTests/TableFormulaTests.swift added +110
| @@ -0,0 +1,110 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct TableFormulaTests { | ||
| 6 | static let tables = [ | ||
| 7 | "| a | b |\n|---+---|\n| 1 | |\n| 2 | |\n| 3 | |\n#+TBLFM: $2=$1*2\n", | ||
| 8 | "| x | y | sum |\n|---+---+-----|\n| 1 | 2 | |\n| 3.5 | 4 | |\n|---+---+-----|\n| | | |\n#+TBLFM: $3=$1+$2::@>$1=vsum(@I..@II)::@>$2=vmean(@2$2..@3$2)\n", | ||
| 9 | "| qty | price | total | share |\n|-----+-------+-------+-------|\n| 3 | 19.99 | | |\n| 1 | 5 | | |\n| 12 | 0.5 | | |\n|-----+-------+-------+-------|\n| | | | |\n#+TBLFM: $3=$1*$2;%.2f::$4=$3/@>$3;f3::@>$3=vsum(@I..@II)\n", | ||
| 10 | "| n | sq | cube | root |\n|---+----+------+------|\n| 1 | | | |\n| 2 | | | |\n| 10 | | | |\n#+TBLFM: $2=$1^2::$3=$1^3::$4=sqrt($1)\n", | ||
| 11 | "| a | b |\n| 1 | |\n| 2 | |\n| 7 | |\n#+TBLFM: $2=$1/2\n", | ||
| 12 | "| a | prev | next |\n|---+------+------|\n| 1 | | |\n| 2 | | |\n| 3 | | |\n#+TBLFM: $2=@-1$1::$3=@+1$1\n", | ||
| 13 | "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n| 4 | 5 | |\n#+TBLFM: $3=$-1*$-2::@2$3=100\n", | ||
| 14 | "| r | v |\n|---+---|\n| 1 | |\n| 2 | |\n| 3 | |\n#+TBLFM: @2$2..@4$2=$1*10\n", | ||
| 15 | "| r | v |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: $>=@#*$#\n", | ||
| 16 | "| a | b | c |\n|---+---+---|\n| 1 | | |\n| | 2 | |\n#+TBLFM: $3=$1+$2\n", | ||
| 17 | "| a | b |\n|---+---|\n| 5 | |\n| 6 | |\n#+TBLFM: $2=$1*3\n#+TBLFM: $2=$1+100\n", | ||
| 18 | "| a | b |\n|---+---|\n| 1 |\n| 2 | |\n#+TBLFM: $3=$1*$1\n", | ||
| 19 | "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3=vsum($1..$2)::@2=7\n", | ||
| 20 | "| a | b |\n|---+---|\n| 1.25 | |\n| 0.1 | |\n| 1e3 | |\n| -2 | |\n#+TBLFM: $2=$1/7\n", | ||
| 21 | "| deg | sin | cos |\n|-----+-----+-----|\n| 0 | | |\n| 30 | | |\n| 45 | | |\n| 90 | | |\n#+TBLFM: $2=sin($1)::$3=cos($1);%.3f\n", | ||
| 22 | " | a | b |\n |---+---|\n | 2 | |\n | 3 | |\n #+TBLFM: $2=$1^0.5;n4\n", | ||
| 23 | "| a | b |\n|---+---|\n| x | |\n| 2 | |\n#+TBLFM: $2=$1*2\n", | ||
| 24 | "| a | b |\n|---+---|\n| 1 | |\n|---+---|\n| 2 | |\n| 3 | |\n#+TBLFM: $2=@I$1+@II$1+@-I$1\n", | ||
| 25 | "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n#+TBLFM: $3=($1+$2)*2/3-1::$1=$1\n", | ||
| 26 | "| a |\n|---|\n| 1 |\n| 2 |\n|---|\n| |\n#+TBLFM: @>$1=vmax(@I..@II)-vmin(@I..@II)+vcount(@I..@II)+vmedian(@I..@II)\n", | ||
| 27 | ] | ||
| 28 | |||
| 29 | static func carets(_ text: String) -> [Int] { | ||
| 30 | let ns = text as NSString | ||
| 31 | var result: [Int] = [] | ||
| 32 | var location = 0 | ||
| 33 | while location < ns.length { | ||
| 34 | let line = ns.lineRange(for: NSRange(location: location, length: 0)) | ||
| 35 | let content = ns.substring(with: line) | ||
| 36 | if let bar = content.firstIndex(of: "|"), !content.contains("|-") { | ||
| 37 | // Inside the first field of the row. | ||
| 38 | result.append(line.location + content.distance(from: content.startIndex, to: bar) + 2) | ||
| 39 | } | ||
| 40 | if content.uppercased().contains("#+TBLFM") { result.append(line.location + 3) } | ||
| 41 | location = NSMaxRange(line) | ||
| 42 | } | ||
| 43 | return result | ||
| 44 | } | ||
| 45 | |||
| 46 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 47 | func matchesEmacs() throws { | ||
| 48 | var cases: [EmacsOracle.Case] = [] | ||
| 49 | var ours: [(text: String, caret: Int, failure: String?)] = [] | ||
| 50 | for text in Self.tables { | ||
| 51 | for caret in Self.carets(text) { | ||
| 52 | let onTblfm = (text as NSString).substring(with: (text as NSString).lineRange(for: NSRange(location: caret, length: 0))).uppercased().contains("#+TBLFM") | ||
| 53 | let commands: [(any OrgCommand, String)] = onTblfm | ||
| 54 | ? [(TableRecalculate(all: true), "(org-ctrl-c-ctrl-c)")] | ||
| 55 | : [(TableRecalculate(all: true), "(org-table-recalculate t)"), (TableRecalculate(all: false), "(org-table-recalculate nil)")] | ||
| 56 | for (command, form) in commands { | ||
| 57 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: form)) | ||
| 58 | ours.append(runCommand(command, text, caret: caret)) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | let emacs = try EmacsOracle.run(cases) | ||
| 63 | var fallbacks = 0 | ||
| 64 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | ||
| 65 | if let failure = mine.failure, failure.hasPrefix("external:") { | ||
| 66 | fallbacks += 1 | ||
| 67 | // Emacs gives symbolic results for text and division by zero. | ||
| 68 | #expect(failure.contains("non-numeric field") || failure.contains("division by zero"), "\(failure)") | ||
| 69 | continue | ||
| 70 | } | ||
| 71 | if !theirs.error.isEmpty { | ||
| 72 | // Emacs keeps the fields written before the error; nothing changes here. | ||
| 73 | #expect(mine.failure == theirs.error && mine.text == cases[i].text) | ||
| 74 | continue | ||
| 75 | } | ||
| 76 | let expected = (theirs.text, EmacsOracle.offset(theirs.point, in: theirs.text), theirs.error.isEmpty ? nil : theirs.error) | ||
| 77 | #expect(mine.text == expected.0 && mine.caret == expected.1 && mine.failure == expected.2, | ||
| 78 | "\(cases[i].form) at \(cases[i].point) in\n\(cases[i].text)\nours:\n\(mine.text)@\(mine.caret) \(mine.failure ?? "")\nemacs:\n\(theirs.text)@\(expected.1) \(theirs.error)") | ||
| 79 | } | ||
| 80 | #expect(fallbacks < ours.count / 2, "\(fallbacks) fallbacks") | ||
| 81 | } | ||
| 82 | |||
| 83 | /// `ORGSTAR_ORACLE_CORPUS=<folder>`: C-c C-c on every `#+TBLFM` line below the folder. | ||
| 84 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) | ||
| 85 | func corpusMatchesEmacs() throws { | ||
| 86 | let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!) | ||
| 87 | let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)! | ||
| 88 | .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path } | ||
| 89 | var cases: [EmacsOracle.Case] = [] | ||
| 90 | var ours: [(text: String, caret: Int, failure: String?)] = [] | ||
| 91 | for file in files { | ||
| 92 | guard let text = try? String(contentsOf: file, encoding: .utf8), text.uppercased().contains("#+TBLFM") else { continue } | ||
| 93 | let ns = text as NSString | ||
| 94 | var location = 0 | ||
| 95 | while location < ns.length { | ||
| 96 | let line = ns.lineRange(for: NSRange(location: location, length: 0)) | ||
| 97 | if ns.substring(with: line).trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("#+TBLFM") { | ||
| 98 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(line.location, in: text), form: "(org-ctrl-c-ctrl-c)")) | ||
| 99 | ours.append(runCommand(TableRecalculate(all: true), text, caret: line.location)) | ||
| 100 | } | ||
| 101 | location = NSMaxRange(line) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | let emacs = try EmacsOracle.run(cases) | ||
| 105 | for (mine, theirs) in zip(ours, emacs) where !(mine.failure ?? "").hasPrefix("external:") { | ||
| 106 | #expect(mine.text == theirs.text, "\(mine.failure ?? "")") | ||
| 107 | } | ||
| 108 | print("TBLFM lines: \(cases.count), native: \(ours.filter { !($0.failure ?? "").hasPrefix("external:") }.count)") | ||
| 109 | } | ||
| 110 | } | ||
Tests/OrgCoreTests/TimestampCommandTests.swift +1
| @@ -17,6 +17,7 @@ struct TimestampCommandTests { | |||
| 17 | return (new, result.selection?.first?.lowerBound ?? caret, nil) | 17 | return (new, result.selection?.first?.lowerBound ?? caret, nil) |
| 18 | case .failed(let message): return (text, caret, message) | 18 | case .failed(let message): return (text, caret, message) |
| 19 | case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)") | 19 | case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)") |
| 20 | case .external(let request): return (text, caret, "external \(request)") | ||
| 20 | } | 21 | } |
| 21 | } | 22 | } |
| 22 | 23 | ||
docs/plans/2026-10-05-table-formulas.md added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | # Table Formulas Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** `#+TBLFM` recalculation (`C-c *`, `C-u C-c *`, `C-c C-c` on a `#+TBLFM` line) computed natively where Calc's results can be reproduced exactly, and in Emacs otherwise. | ||
| 6 | |||
| 7 | **Architecture:** `OrgCore/Compute/Calc.swift` implements the Calc subset table formulas use with Calc's number model: exact integers, decimal floats rounded half-up to 12 digits after each operation (`math-make-float`, `math-div-float`, `math-ipow`), `*` binding tighter than `/`, exact results for special trig and log values, and `math-format-number` for `float`, `fix`, `sci` and `eng` formats. `TableFormulas.swift` ports `org-table-recalculate`, `org-table-eval-formula`, `org-table-get-range`, row descriptors (`@I`, `@-1`, `@<`, `@>`), lhs ranges and `org-table-justify-field-maybe`, then aligns with the existing `org-table-align` port. Anything outside the domain (text in a referenced field, division by zero, names, constants, `remote()`, Lisp, durations, unsupported functions or flags) returns `.external(.emacsTable)`: the app runs `emacs --batch` on a snapshot of the buffer in the file's folder and splices back the table text if the table is unchanged. Lisp formulas ask first, with "always" stored by content hash in `trusted.json`. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 / Calc oracle. | ||
| 10 | |||
| 11 | **Spec:** `docs/design.md`, "Table formula evaluator". | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Calc oracle: 475 expressions (fixed and random) against `calc-eval` with `org-calc-default-modes`, every display format. | ||
| 16 | - Table oracle: 20 tables × every row and `#+TBLFM` line × `org-table-recalculate t`/`nil` and `org-ctrl-c-ctrl-c`, text and point; corpus `#+TBLFM` lines. | ||
| 17 | - On an error partway through, Emacs keeps the fields already written; here nothing changes. | ||
| 18 | - New command step `.external(ExternalRequest)` for work done outside the text. | ||
| 19 | |||
| 20 | ## Defaults chosen (user may change) | ||
| 21 | |||
| 22 | - Keys: `C-c *` recalculates the row, `C-c C-c` on `#+TBLFM` the table; "Recalculate Table" in the palette for `C-u C-c *`. | ||
| 23 | - Emacs is found at `ORGSTAR_EMACS`, Homebrew, `/usr/local/bin` or Emacs.app. | ||
| 24 | |||
| 25 | --- | ||
| 26 | |||
| 27 | ### Task 1: Calc | ||
| 28 | |||
| 29 | - [x] Numbers, operators, functions, formats; oracle. | ||
| 30 | |||
| 31 | ### Task 2: Recalculation | ||
| 32 | |||
| 33 | - [x] `tableRecalculate`, `TableRecalculate`, `tblfm` key context, bindings; oracle; corpus. | ||
| 34 | |||
| 35 | ### Task 3: Emacs fallback | ||
| 36 | |||
| 37 | - [x] `.external`, `EmacsRunner.recalculateTable`, `DocumentSession.recalculateInEmacs`, `TrustStore`, editor wiring; test with a text field. | ||
| 38 | - [x] Commit "Table formulas". | ||