Table formulas !24

merged merged by cmc on 2026-10-05 06:51 UTC · krz/orgstar:phase4-tblfm into main

19 files changed, +1676 −2

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +27
@@ -316,6 +316,33 @@ public final class DocumentSession {
316316 }
317317 }
318318
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
319346 // MARK: - Locations
320347
321348 /// 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 @@
1import Foundation
2
3/// Runs `emacs --batch` for what the native code hands over, such as tables outside the
4/// native formula domain.
5public 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 @@
1import CryptoKit
2import 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
7public 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 {
327327 case .commit(let result): return .success(Edits(source: result, archive: archiveEdit))
328328 case .failed(let message): return .failure(.message(message))
329329 case .prompt: return .failure(.message("unexpected prompt"))
330 case .external: return .failure(.message("unexpected external request"))
330331 }
331332 }
332333}
Sources/OrgCore/Commands/Command.swift +10
@@ -137,6 +137,15 @@ public enum CommandStep: Sendable, Equatable {
137137 /// The command can't run here; nothing changes. The message is for the user, as org's
138138 /// `user-error`.
139139 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
145public 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)
140149}
141150
142151/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -162,6 +171,7 @@ public enum Commands {
162171 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
163172 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
164173 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
174 TableRecalculate(all: false), TableRecalculate(all: true),
165175 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
166176 ]
167177
Sources/OrgCore/Compute/Calc.swift added +600
@@ -0,0 +1,600 @@
1import 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.
7public 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 @@
1import 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
8extension 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.
548public 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 {
103103
104104/// Context names a binding's `when` can use.
105105public 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"]
107107
108108 public static func holds(_ name: String, in context: EditContext) -> Bool {
109109 switch name {
@@ -112,6 +112,7 @@ public enum KeyContext {
112112 case "item": return enclosing(.item, context) != nil
113113 case "region": return context.selection.contains { !$0.isEmpty }
114114 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
115 case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil
115116 default: return false
116117 }
117118 }
Sources/OrgCore/Keymap/Presets.swift +8
@@ -289,6 +289,14 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
289289 command = "org.table.align"
290290 when = "table"
291291 [[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]]
292300 keys = "M-<up>"
293301 command = "org.table.row-up"
294302 when = "table"
Sources/OrgEditorAppKit/OrgEditor.swift +4
@@ -184,6 +184,8 @@ public final class OrgEditor: NSObject {
184184 public var onAppCommand: ((String) -> Void)?
185185 /// Called when a command asks a question; reply with the answer, or nil to cancel.
186186 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)?
187189
188190 public var keymap: Keymap {
189191 get { dispatcher.keymap }
@@ -324,6 +326,8 @@ public final class OrgEditor: NSObject {
324326 for case .message(let text) in result.effects { onMessage?(text) }
325327 case .failed(let message):
326328 onMessage?(message)
329 case .external(let request):
330 if let onExternal { onExternal(request) } else { onMessage?("This needs Emacs, which isn't available here.") }
327331 case .prompt(let prompt):
328332 onPrompt?(prompt) { [weak self] answer in
329333 guard let self else { return }
Sources/Orgstar/AgendaView.swift +2
@@ -297,6 +297,8 @@ struct AgendaView: View {
297297 case .failed(let text):
298298 message = text
299299 tick += 1
300 case .external:
301 message = "Not available from the agenda"
300302 case .commit:
301303 message = nil
302304 tick += 1
Sources/Orgstar/ContentView.swift +25 −1
@@ -13,6 +13,7 @@ struct ContentView: View {
1313 @AppStorage("reminderLead") private var reminderLead = 12
1414 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
1515 @State private var reminderAgenda: AgendaModel?
16 @State private var trust = TrustStore()
1617 @State private var hour = 0
1718 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
1819 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -56,7 +57,7 @@ struct ContentView: View {
5657 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
5758 } detail: {
5859 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)
6061 .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) }
6162 } else {
6263 ContentUnavailableView(
@@ -165,6 +166,29 @@ struct ContentView: View {
165166 }
166167 }
167168
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
168192 private struct ReminderKey: Hashable {
169193 var enabled: Bool
170194 var lead: Int
Sources/Orgstar/EditorView.swift +2
@@ -17,6 +17,7 @@ struct EditorView: NSViewRepresentable {
1717 let optionAsMeta: OptionAsMeta
1818 let options: EditingOptions
1919 let onAppCommand: (String) -> Void
20 let onExternal: (ExternalRequest) -> Void
2021
2122 final class Coordinator {
2223 var editor: OrgEditor?
@@ -45,6 +46,7 @@ struct EditorView: NSViewRepresentable {
4546 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
4647 }
4748 editor.onAppCommand = onAppCommand
49 editor.onExternal = onExternal
4850 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
4951 commands.editor = editor
5052 let scroll = editor.makeScrollView()
Tests/OrgAppTests/AppTests.swift +21
@@ -420,3 +420,24 @@ struct ReminderSchedulerTests {
420420 #expect(center.ids == ["someone.else"])
421421 }
422422}
423
424@MainActor
425struct 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 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Runs `calc-eval` with org's table modes over a list of expressions.
6enum 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
39struct 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:
144144 return (text, caret, message)
145145 case .prompt(let prompt):
146146 return (text, caret, "prompt: \(prompt.key)")
147 case .external(let request):
148 return (text, caret, "external: \(request)")
147149 }
148150}
Tests/OrgCoreTests/TableFormulaTests.swift added +110
@@ -0,0 +1,110 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct 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 {
1717 return (new, result.selection?.first?.lowerBound ?? caret, nil)
1818 case .failed(let message): return (text, caret, message)
1919 case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)")
20 case .external(let request): return (text, caret, "external \(request)")
2021 }
2122 }
2223
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".