Evaluate more table formulas natively !127

merged merged by cmc on 2026-10-07 04:21 UTC · krz/orgstar:native-formulas into main

5 files changed, +628 −76

Layout: unified · split

Sources/OrgCore/Compute/Calc.swift +227 −14
@@ -11,11 +11,21 @@ public enum Calc {
1111 init(_ reason: String) { self.reason = reason }
1212 }
1313
14 public enum Value: Equatable, Sendable {
14 /// An error Calc reports, which a table shows as `#ERROR`.
15 public struct Failure: Error, Equatable, CustomStringConvertible {
16 public let reason: String
17 public var description: String { reason }
18 init(_ reason: String) { self.reason = reason }
19 }
20
21 public indirect enum Value: Equatable, Sendable {
1522 case int(Int)
1623 /// Mantissa and exponent, normalized: at most `precision` digits, no trailing zeros.
1724 case float(Int, Int)
1825 case vector([Value])
26 /// A date form: the day number, with the time of day as a fraction.
27 case date(Value)
28 case nan
1929 }
2030
2131 public enum FloatFormat: Equatable, Sendable {
@@ -29,12 +39,19 @@ public enum Calc {
2939 public init() {}
3040 }
3141
32 /// `calc-eval` of `expression`, as the string Calc would return.
33 public static func evaluate(_ expression: String, modes: Modes = Modes()) throws -> String {
42 /// `calc-eval` of `expression`, as the string Calc would return; with `numeric`, as
43 /// `calc-eval` with `num`, a result that isn't a number is an error.
44 public static func evaluate(_ expression: String, modes: Modes = Modes(), numeric: Bool = false) throws -> String {
3445 guard modes.precision == 12 else { throw Unsupported("calc precision") }
3546 var parser = Parser(expression)
3647 let tree = try parser.parse()
3748 let value = try Evaluator(modes: modes).eval(tree)
49 if numeric {
50 switch value {
51 case .int, .float: break
52 default: throw Failure("Number expected")
53 }
54 }
3855 return try format(value, modes.format)
3956 }
4057
@@ -95,6 +112,7 @@ public enum Calc {
95112 case .int(let i): return (Int128(i), 0)
96113 case .float(let m, let e): return (Int128(m), e)
97114 case .vector: throw Unsupported("vector arithmetic")
115 case .date, .nan: throw Unsupported("date arithmetic")
98116 }
99117 }
100118
@@ -103,6 +121,7 @@ public enum Calc {
103121 case .int(let i): return Double(i)
104122 case .float(let m, let e): return Double(m) * pow(10, Double(e))
105123 case .vector: throw Unsupported("vector arithmetic")
124 case .date, .nan: throw Unsupported("date arithmetic")
106125 }
107126 }
108127
@@ -118,6 +137,14 @@ public enum Calc {
118137 }
119138
120139 static func add(_ a: Value, _ b: Value) throws -> Value {
140 switch (a, b) {
141 case (.nan, .vector), (.vector, .nan): throw Unsupported("vector arithmetic")
142 case (.nan, _), (_, .nan): return .nan
143 case (.date, .date): throw Unsupported("date arithmetic")
144 case (.date(let d), _): return .date(try add(d, b))
145 case (_, .date(let d)): return .date(try add(a, d))
146 default: break
147 }
121148 if case .int(let x) = a, case .int(let y) = b {
122149 let (sum, overflow) = x.addingReportingOverflow(y)
123150 if overflow { throw Unsupported("integer size") }
@@ -143,10 +170,26 @@ public enum Calc {
143170 return .int(-i)
144171 case .float(let m, let e): return .float(-m, e)
145172 case .vector: throw Unsupported("vector arithmetic")
173 case .date: throw Unsupported("date arithmetic")
174 case .nan: return .nan
146175 }
147176 }
148177
178 /// `a - b`: a date less a date is the days between them.
179 static func subtract(_ a: Value, _ b: Value) throws -> Value {
180 if case .date(let x) = a, case .date(let y) = b { return try add(x, try negate(y)) }
181 if case .date(let x) = a, b != .nan { return .date(try add(x, try negate(b))) }
182 return try add(a, try negate(b))
183 }
184
185 static func isNaN(_ a: Value, _ b: Value) -> Bool {
186 if case .vector = a { return false }
187 if case .vector = b { return false }
188 return a == .nan || b == .nan
189 }
190
149191 static func multiply(_ a: Value, _ b: Value) throws -> Value {
192 if isNaN(a, b) { return .nan }
150193 if case .int(let x) = a, case .int(let y) = b {
151194 let (product, overflow) = x.multipliedReportingOverflow(by: y)
152195 if overflow { throw Unsupported("integer size") }
@@ -160,6 +203,7 @@ public enum Calc {
160203 }
161204
162205 static func divide(_ a: Value, _ b: Value) throws -> Value {
206 if isNaN(a, b) { return .nan }
163207 if case .int(let x) = a, case .int(let y) = b {
164208 guard y != 0 else { throw Unsupported("division by zero") }
165209 if x % y == 0 { return .int(x / y) }
@@ -189,6 +233,7 @@ public enum Calc {
189233 }
190234
191235 static func power(_ a: Value, _ b: Value) throws -> Value {
236 if isNaN(a, b) { return .nan }
192237 if case .int(let n) = b { return try integerPower(a, n) }
193238 let base = try double(a)
194239 guard base >= 0 else { throw Unsupported("complex result") }
@@ -196,6 +241,8 @@ public enum Calc {
196241 }
197242
198243 static func compare(_ a: Value, _ b: Value) throws -> Int {
244 if case .date(let x) = a, case .date(let y) = b { return try compare(x, y) }
245 if a == .nan || b == .nan { throw Unsupported("comparison with nan") }
199246 let difference = try add(a, try negate(b))
200247 let (m, _) = try parts(difference)
201248 return m == 0 ? 0 : (m < 0 ? -1 : 1)
@@ -208,6 +255,8 @@ public enum Calc {
208255 switch value {
209256 case .int(let i): return String(i)
210257 case .vector: throw Unsupported("vector result")
258 case .nan: return "nan"
259 case .date(let d): return "<" + (try formatDate(d)) + ">"
211260 case .float(let m, _) where m < 0:
212261 guard case .float(let m, let e) = value else { fatalError() }
213262 return "-" + (try format(.float(-m, e), floatFormat))
@@ -263,6 +312,34 @@ public enum Calc {
263312 }
264313 }
265314
315 static let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
316
317 /// `calc-date-format` as Org sets it: `YYYY-MM-DD Www`, then ` hh:mm` for a time of day.
318 static func formatDate(_ value: Value) throws -> String {
319 let days = try double(value)
320 let day = Int(days.rounded(.down))
321 let date = Days.date(day)
322 var text = String(format: "%04d-%02d-%02d ", date.year, date.month, date.day) + weekdays[Days.weekday(day)]
323 if case .float = value, days != Double(day) {
324 let seconds = Int(((days - Double(day)) * 86400).rounded())
325 text += String(format: " %02d:%02d", seconds / 3600, seconds / 60 % 60)
326 }
327 return text
328 }
329
330 /// A date form `<YYYY-MM-DD Www>` or `<YYYY-MM-DD Www hh:mm>`, from the text between
331 /// the brackets.
332 static func parseDate(_ text: String) throws -> Value {
333 guard let m = text.firstMatch(of: /^([0-9]{4})-([0-9]{2})-([0-9]{2})(?: +[A-Za-z]+)?(?: +([0-9]{1,2}):([0-9]{2})(?::([0-9]{2}))?)?$/) else {
334 throw Unsupported("date syntax")
335 }
336 let day = Days.absolute(year: Int(m.1)!, month: Int(m.2)!, day: Int(m.3)!)
337 guard let hour = m.4.flatMap({ Int($0) }), let minute = m.5.flatMap({ Int($0) }) else { return .date(.int(day)) }
338 let seconds = hour * 3600 + minute * 60 + (m.6.flatMap { Int($0) } ?? 0)
339 if seconds == 0 { return .date(.int(day)) }
340 return .date(try add(.int(day), try divide(.int(seconds), .int(86400))))
341 }
342
266343 // MARK: - Parsing
267344
268345 indirect enum Node {
@@ -289,11 +366,50 @@ public enum Calc {
289366 }
290367
291368 mutating func parse() throws -> Node {
292 let node = try sum()
369 let node = try or()
293370 guard peek() == nil else { throw Unsupported("formula syntax") }
294371 return node
295372 }
296373
374 mutating func take(_ token: String) -> Bool {
375 guard peek() != nil else { return false }
376 let t = Array(token)
377 guard i + t.count <= chars.count, Array(chars[i..<(i + t.count)]) == t else { return false }
378 i += t.count
379 return true
380 }
381
382 // `||` < `&&` < `!` < comparisons < sums.
383 mutating func or() throws -> Node {
384 var left = try and()
385 while take("||") { left = .binary("|", left, try and()) }
386 return left
387 }
388
389 mutating func and() throws -> Node {
390 var left = try not()
391 while take("&&") { left = .binary("&", left, try not()) }
392 return left
393 }
394
395 mutating func not() throws -> Node {
396 if peek() == "!", i + 1 >= chars.count || chars[i + 1] != "=" {
397 i += 1
398 return .unary("!", try not())
399 }
400 return try comparison()
401 }
402
403 mutating func comparison() throws -> Node {
404 let left = try sum()
405 for (token, op) in [("==", "="), ("!=", "≠"), ("<=", "≤"), (">=", "≥"), ("<", "<"), (">", ">")] where take(token) {
406 let right = try sum()
407 if let next = peek(), "=!<>".contains(next) { throw Unsupported("chained comparison") }
408 return .binary(Character(op), left, right)
409 }
410 return left
411 }
412
297413 // `+ -` < `/` < `*` < unary minus < `^`, as Calc's operator table orders them.
298414 mutating func sum() throws -> Node {
299415 var left = try quotient()
@@ -306,9 +422,9 @@ public enum Calc {
306422
307423 mutating func quotient() throws -> Node {
308424 var left = try product()
309 while peek() == "/" {
425 while let c = peek(), c == "/" || c == "%" || c == "\\" {
310426 i += 1
311 left = .binary("/", left, try product())
427 left = .binary(c, left, try product())
312428 }
313429 return left
314430 }
@@ -344,7 +460,7 @@ public enum Calc {
344460 guard let c = peek() else { throw Unsupported("formula syntax") }
345461 if c == "(" {
346462 i += 1
347 let inner = try sum()
463 let inner = try or()
348464 guard peek() == ")" else { throw Unsupported("formula syntax") }
349465 i += 1
350466 return inner
@@ -353,10 +469,10 @@ public enum Calc {
353469 i += 1
354470 var items: [Node] = []
355471 if peek() != "]" {
356 items.append(try sum())
472 items.append(try or())
357473 while peek() == "," {
358474 i += 1
359 items.append(try sum())
475 items.append(try or())
360476 }
361477 }
362478 guard peek() == "]" else { throw Unsupported("formula syntax") }
@@ -364,20 +480,29 @@ public enum Calc {
364480 return .vector(items)
365481 }
366482 if c.isASCII, c.isNumber || c == "." { return .number(try number()) }
483 if c == "<" {
484 guard let close = chars[i...].firstIndex(of: ">") else { throw Unsupported("formula syntax") }
485 let text = String(chars[(i + 1)..<close])
486 i = close + 1
487 return .number(try parseDate(text))
488 }
367489 if c.isLetter {
368490 var name = ""
369491 while i < chars.count, chars[i].isLetter || chars[i].isNumber || chars[i] == "_" {
370492 name.append(chars[i])
371493 i += 1
372494 }
373 guard peek() == "(" else { throw Unsupported("variable \(name)") }
495 guard peek() == "(" else {
496 if name == "nan" { return .number(.nan) }
497 throw Unsupported("variable \(name)")
498 }
374499 i += 1
375500 var args: [Node] = []
376501 if peek() != ")" {
377 args.append(try sum())
502 args.append(try or())
378503 while peek() == "," {
379504 i += 1
380 args.append(try sum())
505 args.append(try or())
381506 }
382507 }
383508 guard peek() == ")" else { throw Unsupported("formula syntax") }
@@ -443,22 +568,90 @@ public enum Calc {
443568 switch node {
444569 case .number(let v): return v
445570 case .vector(let items): return .vector(try items.map(eval))
571 case .unary("!", let operand): return .int(try truth(try eval(operand)) ? 0 : 1)
446572 case .unary(_, let operand): return try negate(try eval(operand))
447573 case .binary(let op, let l, let r):
448574 let a = try eval(l)
449575 let b = try eval(r)
450576 switch op {
451577 case "+": return try add(a, b)
452 case "-": return try add(a, try negate(b))
578 case "-": return try subtract(a, b)
453579 case "*": return try multiply(a, b)
454580 case "/": return try divide(a, b)
455 default: return try power(a, b)
581 case "%": return try mod(a, b)
582 case "\\": return try idiv(a, b)
583 case "^": return try power(a, b)
584 case "|": return .int(try truth(a) || truth(b) ? 1 : 0)
585 case "&": return .int(try truth(a) && truth(b) ? 1 : 0)
586 default:
587 let order = try compare(a, b)
588 let holds = switch op {
589 case "=": order == 0
590 case "≠": order != 0
591 case "<": order < 0
592 case ">": order > 0
593 case "≤": order <= 0
594 default: order >= 0
595 }
596 return .int(holds ? 1 : 0)
456597 }
457598 case .call(let name, let args):
458599 return try call(name, try args.map(eval))
459600 }
460601 }
461602
603 /// A condition's value: a number, nonzero for true.
604 func truth(_ v: Value) throws -> Bool {
605 switch v {
606 case .int(let i): return i != 0
607 case .float(let m, _): return m != 0
608 default: throw Unsupported("condition")
609 }
610 }
611
612 /// `math-mod`: the remainder with the divisor's sign.
613 func mod(_ a: Value, _ b: Value) throws -> Value {
614 if case .int(let x) = a, case .int(let y) = b {
615 guard y != 0 else { throw Unsupported("division by zero") }
616 let r = x % y
617 return .int(r != 0 && (r < 0) != (y < 0) ? r + y : r)
618 }
619 let q = try floorOf(try divide(a, b))
620 return try subtract(a, try multiply(b, q))
621 }
622
623 /// `calcFunc-idiv`: the quotient rounded down.
624 func idiv(_ a: Value, _ b: Value) throws -> Value {
625 if case .int(let x) = a, case .int(let y) = b {
626 guard y != 0 else { throw Unsupported("division by zero") }
627 let q = x / y
628 return .int(q * y != x && (x < 0) != (y < 0) ? q - 1 : q)
629 }
630 return try floorOf(try divide(a, b))
631 }
632
633 func floorOf(_ v: Value) throws -> Value {
634 if case .int = v { return v }
635 guard let i = Int(exactly: try double(v).rounded(.down)) else { throw Unsupported("integer size") }
636 return .int(i)
637 }
638
639 /// The sum of squared deviations from the mean, and the count.
640 func deviations(_ args: [Value]) throws -> (Value, Int) {
641 let items = try elements(args)
642 guard !items.isEmpty else { throw Unsupported("empty vector") }
643 let mean = try divide(try items.reduce(Value.int(0)) { try add($0, $1) }, .int(items.count))
644 let squares = try items.reduce(Value.int(0)) { sum, x in
645 let d = try subtract(x, mean)
646 return try add(sum, try multiply(d, d))
647 }
648 return (squares, items.count)
649 }
650
651 func root(_ x: Value) throws -> Value {
652 try call("sqrt", [x])
653 }
654
462655 func elements(_ args: [Value]) throws -> [Value] {
463656 guard args.count == 1 else { throw Unsupported("arguments") }
464657 if case .vector(let items) = args[0] { return items }
@@ -500,6 +693,26 @@ public enum Calc {
500693 try items.sort { try compare($0, $1) < 0 }
501694 if items.count % 2 == 1 { return items[items.count / 2] }
502695 return try divide(try add(items[items.count / 2 - 1], items[items.count / 2]), .int(2))
696 case "if":
697 guard args.count == 3 else { throw Unsupported("arguments") }
698 return try truth(args[0]) ? args[1] : args[2]
699 case "vvar", "vpvar", "vsdev", "vpsdev":
700 let (squares, n) = try deviations(args)
701 let population = name == "vpvar" || name == "vpsdev"
702 if !population, n < 2 { throw Unsupported("variance of one value") }
703 let variance = try divide(squares, .int(population ? n : n - 1))
704 return name.hasSuffix("sdev") ? try root(variance) : variance
705 case "mod":
706 guard args.count == 2 else { throw Unsupported("arguments") }
707 return try mod(args[0], args[1])
708 case "idiv":
709 guard args.count == 2 else { throw Unsupported("arguments") }
710 return try idiv(args[0], args[1])
711 case "fact":
712 guard case .int(let n) = try one(args), n >= 0 else { throw Unsupported("arguments") }
713 var result = Value.int(1)
714 for k in stride(from: 2, through: n, by: 1) { result = try multiply(result, .int(k)) }
715 return result
503716 case "max", "min":
504717 guard !args.isEmpty else { throw Unsupported("arguments") }
505718 var best = args[0]
Sources/OrgCore/Compute/TableFormulas.swift +350 −60
@@ -1,9 +1,9 @@
11import Foundation
22
33// 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.
4// `org-table-eval-formula` with Calc and Lisp evaluated natively. Symbolic results, Calc
5// features beyond `Calc`, Lisp beyond `Elisp`, remote tables found by ID and relative field
6// targets throw `Calc.Unsupported`; the caller then recalculates the table in Emacs.
77
88extension EmacsBuffer {
99 struct TableAnalysis {
@@ -13,6 +13,14 @@ extension EmacsBuffer {
1313 var types: [Bool]
1414 var ncol: Int
1515 var begin: Int
16 /// `org-table-column-names`: from the first `!` row.
17 var columnNames: [(name: String, column: Int)] = []
18 /// `org-table-local-parameters`, in the order org pushes them; the last match wins.
19 var parameters: [(name: String, value: String)] = []
20 /// `org-table-named-field-locations`: the table-relative line and the column.
21 var namedFields: [(name: String, line: Int, column: Int)] = []
22 /// A row marked for calculation (`org-table-calculate-mark-regexp`).
23 var marked = false
1624 }
1725
1826 static let tblfmPattern = "^[ \\t]*#\\+TBLFM: *(.*)"
@@ -26,13 +34,12 @@ extension EmacsBuffer {
2634 point = begin
2735 var dlines = [0]
2836 var types: [Bool] = []
37 var lines: [String] = []
2938 var line = 0
3039 while point < end || (point == end && false) {
3140 let content = substring(lineStart(point), lineEnd(point))
3241 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 }
42 lines.append(content)
3643 let isHline = content.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil
3744 types.append(isHline)
3845 if !isHline { dlines.append(line) }
@@ -45,10 +52,43 @@ extension EmacsBuffer {
4552 forwardLine(dlines[1])
4653 let first = substring(lineStart(point), lineEnd(point))
4754 let ncol = Self.splitFields(first, separator: "[ \\t]*\\|[ \\t]*").count
48 return TableAnalysis(dlines: dlines, types: types, ncol: ncol, begin: begin)
55 var analysis = TableAnalysis(dlines: dlines, types: types, ncol: ncol, begin: begin)
56 Self.names(lines, into: &analysis)
57 return analysis
4958 }
5059 }
5160
61 /// The names part of `org-table-analyze`.
62 static func names(_ lines: [String], into analysis: inout TableAnalysis) {
63 let identifier = "^[a-zA-Z][_a-zA-Z0-9]*$"
64 func rest(_ line: String, _ pattern: String) -> String? {
65 guard let m = try! NSRegularExpression(pattern: pattern).firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else { return nil }
66 return (line as NSString).substring(with: m.range(at: m.numberOfRanges - 1))
67 }
68 if let names = lines.lazy.compactMap({ rest($0, "^[ \\t]*\\| *! *(\\|.*)") }).first {
69 for (i, name) in splitFields(names, separator: " *\\| *").enumerated() where name.range(of: identifier, options: .regularExpression) != nil {
70 analysis.columnNames.append((name, i + 2))
71 }
72 }
73 for line in lines {
74 guard let fields = rest(line, "^[ \\t]*\\| *\\$ *(\\|.*)") else { continue }
75 for field in splitFields(fields, separator: " *\\| *") {
76 if let m = field.firstMatch(of: /^([a-zA-Z][_a-zA-Z0-9]*|%) *= *(.*)/) { analysis.parameters.append((String(m.1), String(m.2))) }
77 }
78 }
79 for (k, line) in lines.enumerated() {
80 guard let m = line.firstMatch(of: /^[ \t]*\| *([_^]) *(\|.*)/) else { continue }
81 let target = m.1 == "_" ? k + 1 : k - 1
82 guard lines.indices.contains(target), let values = rest(lines[target], "^[ \\t]*\\|[^|]*(\\|.*)") else { continue }
83 for (i, (name, value)) in zip(splitFields(String(m.2), separator: " *\\| *"), splitFields(values, separator: " *\\| *")).enumerated()
84 where name.range(of: identifier, options: .regularExpression) != nil {
85 analysis.parameters.append((name, value))
86 analysis.namedFields.append((name, target, i + 2))
87 }
88 }
89 analysis.marked = lines.contains { $0.range(of: "^[ \\t]*\\| *[!$^_#*] *(\\||$)", options: .regularExpression) != nil }
90 }
91
5292 /// `org-split-string`: leading and trailing separators produce no empty strings.
5393 static func splitFields(_ s: String, separator: String) -> [String] {
5494 let ns = s as NSString
@@ -259,28 +299,180 @@ extension EmacsBuffer {
259299 }
260300
261301 static let calcNumber = "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?$"
302 /// `org-ts-regexp-both`.
303 static let timestampBoth = "[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}(?: [^\\]>\\n]*)?)[\\]>]"
304
305 /// How `org-table-make-reference` turns fields into formula text.
306 struct ReferenceMode {
307 enum Lisp { case none, lisp, literal }
308 var keepEmpty = false
309 var numbers = false
310 var lisp = Lisp.none
311 }
312
313 /// `number-to-string` of `string-to-number`.
314 static func numberText(_ s: String) -> String { Elisp.printed(Elisp.stringToNumber(s)) }
262315
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
316 /// `org-table-make-reference`. For Calc, a field that isn't a number, a date or `nan`
317 /// would be a symbol, which isn't supported.
318 static func makeReference(_ value: TableRangeValue, _ mode: ReferenceMode) throws -> String {
319 func blank(_ s: String) -> Bool { !s.contains { !$0.isWhitespace } }
320 func lisp(_ s: String) -> String {
321 switch mode.lisp {
322 case .literal: s
323 default: mode.numbers ? numberText(s) : Elisp.printed(.string(s), escape: true)
324 }
268325 }
326 func calc(_ s: String) throws -> String {
327 let text = mode.numbers ? numberText(s) : s
328 guard text.range(of: calcNumber, options: .regularExpression) != nil || text == "nan"
329 || text.range(of: "^" + timestampBoth + "$", options: .regularExpression) != nil else {
330 throw Calc.Unsupported("non-numeric field \(text)")
331 }
332 return text
333 }
334 let empty = !mode.keepEmpty || mode.numbers ? "0" : "nan"
269335 switch value {
270336 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: ",") + "]"
337 if mode.lisp != .none { return lisp(s) }
338 return blank(s) ? (empty == "0" ? "(0)" : "nan") : "(" + (try calc(s)) + ")"
339 case .list(var items):
340 if !mode.keepEmpty { items = items.filter { !blank($0) } }
341 if mode.lisp != .none { return items.map(lisp).joined(separator: " ") }
342 return "[" + (try items.map { blank($0) ? empty : try calc($0) }).joined(separator: ",") + "]"
343 }
344 }
345
346 /// `org-table-time-string-to-seconds`.
347 static func timeStringToSeconds(_ s: String) -> String {
348 guard !s.isEmpty else { return s }
349 func seconds(_ sign: Substring, _ parts: [Substring]) -> String {
350 let total = parts.enumerated().reduce(0) { sum, part in
351 sum + (Int(part.element) ?? 0) * [3600, 60, 1][part.offset]
352 }
353 return String(sign.isEmpty ? total : -total)
354 }
355 if let m = s.firstMatch(of: /(-?)([0-9]+):([0-9]+):([0-9]+)/) { return seconds(m.1, [m.2, m.3, m.4]) }
356 if s.range(of: timestampBoth, options: .regularExpression) == nil, let m = s.firstMatch(of: /(-?)([0-9]+):([0-9]+)/) {
357 return seconds(m.1, [m.2, m.3])
358 }
359 return numberText(s)
360 }
361
362 enum DurationFormat { case hms, hhmm, hours }
363
364 /// `org-table-time-seconds-to-string`, with `org-table-duration-custom-format` `hours`
365 /// and zero-padded hours.
366 static func secondsToString(_ seconds: Sexp, _ format: DurationFormat) -> String {
367 let value = (try? Elisp.number(seconds))?.double ?? 0
368 let magnitude = abs(value)
369 let result: String
370 switch format {
371 case .hours:
372 result = String(format: "%.2f", magnitude / 3600)
373 case .hms, .hhmm:
374 let total = Int(magnitude.rounded(.down))
375 let hms = String(format: "%02d:%02d:%02d", total / 3600, total / 60 % 60, total % 60)
376 result = format == .hhmm ? String(hms.dropLast(3)) : hms
377 }
378 return value < 0 ? "-" + result : result
379 }
380
381 /// `org-table-convert-refs-to-rc` for a remote reference: `B3` is `@3$2`.
382 static func refsToRC(_ s: String) -> String {
383 let regex = try! NSRegularExpression(pattern: "(?<![A-Za-z0-9_$@])([a-zA-Z]{1,2})([0-9]+)(?![A-Za-z0-9_])")
384 var out = s
385 while let m = regex.firstMatch(in: out, range: NSRange(location: 0, length: (out as NSString).length)) {
386 let ns = out as NSString
387 let column = ns.substring(with: m.range(at: 1)).uppercased().unicodeScalars.reduce(0) { $0 * 26 + Int($1.value) - 64 }
388 out = ns.replacingCharacters(in: m.range, with: "@\(ns.substring(with: m.range(at: 2)))$\(column)")
389 }
390 return out
391 }
392
393 /// `org-table-get-constant`: a parameter or named field, a `#+CONSTANTS` value, or the
394 /// entry's property for `PROP_name`.
395 func tableConstant(_ name: String, analysis: TableAnalysis) -> String {
396 if let parameter = analysis.parameters.last(where: { $0.name == name }) { return parameter.value }
397 for m in (settings.setupKeywords + string).matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) {
398 for pair in m.1.split(whereSeparator: \.isWhitespace) {
399 let parts = pair.split(separator: "=", maxSplits: 1)
400 if parts.count == 2, parts[0] == name { return String(parts[1]) }
401 }
274402 }
403 if name.hasPrefix("PROP_"), let value = inheritedProperty(String(name.dropFirst(5)))?.value { return value }
404 return "#UNDEFINED_NAME"
275405 }
276406
277 /// `org-table-eval-formula` with the equation given, at point.
278 func tableEvalFormula(_ formula: String, analysis: TableAnalysis) throws {
407 /// `org-table-formula-substitute-names`: column names become `$N`; parameters, named
408 /// fields and constants their values, in parentheses outside Lisp formulas.
409 func substituteNames(_ formula: String, analysis: TableAnalysis) -> String {
410 let parenthesize = !formula.hasPrefix("'")
411 let duration = formula.range(of: ";.*[Tt].*$", options: .regularExpression) != nil
412 var new = formula
413 if !analysis.columnNames.isEmpty {
414 let names = analysis.columnNames.map { NSRegularExpression.escapedPattern(for: $0.name) }.joined(separator: "|")
415 let regex = try! NSRegularExpression(pattern: "\\$(\(names))(?![[:alnum:]])")
416 var start = 0
417 while let m = regex.firstMatch(in: new, range: NSRange(location: start, length: (new as NSString).length - start)) {
418 let ns = new as NSString
419 let column = analysis.columnNames.first { $0.name == ns.substring(with: m.range(at: 1)) }!.column
420 new = ns.replacingCharacters(in: m.range, with: "$\(column)")
421 start = m.range.location + 1
422 }
423 }
424 let regex = try! NSRegularExpression(pattern: "\\$([a-zA-Z][_a-zA-Z0-9]*)|(\\bremote\\([^)]*\\))")
425 var start = 0
426 while let m = regex.firstMatch(in: new, range: NSRange(location: start, length: (new as NSString).length - start)) {
427 let ns = new as NSString
428 if m.range(at: 2).location != NSNotFound {
429 start = NSMaxRange(m.range(at: 2))
430 continue
431 }
432 start = m.range.location + 1
433 var value = tableConstant(ns.substring(with: m.range(at: 1)), analysis: analysis)
434 if duration, value.contains(where: { !$0.isWhitespace }) { value = Self.timeStringToSeconds(value) }
435 new = ns.replacingCharacters(in: m.range, with: parenthesize ? "(" + value + ")" : value)
436 }
437 return new
438 }
439
440 /// `org-table-get-remote-range`: the table after `#+NAME: name` in this file.
441 func remoteRange(_ name: String, _ form: String) throws -> TableRangeValue {
442 let ns = string as NSString
443 let pattern = "^[ \\t]*#\\+(tbl)?name:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$"
444 let keyword = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines, .caseInsensitive])
445 guard let found = keyword.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else {
446 throw Calc.Unsupported("remote table \(name) by ID")
447 }
448 let after = min(ns.length, found.range.location + 1)
449 let next = try! NSRegularExpression(pattern: "^(\\*+ )|^[ \\t]*\\|", options: .anchorsMatchLines)
450 guard let m = next.firstMatch(in: string, range: NSRange(location: after, length: ns.length - after)),
451 m.range(at: 1).location == NSNotFound else {
452 throw UserError(message: "Cannot find a table at NAME or ID \(name)")
453 }
454 let remote = EmacsBuffer(string, point: m.range.location, settings: settings, options: options)
455 let analysis = try remote.tableAnalyze()
456 let resolved = remote.substituteNames(try Self.handleFirstLast(Self.refsToRC(form), analysis: analysis), analysis: analysis)
457 let range = try! NSRegularExpression(pattern: Self.rangePattern)
458 let rns = resolved as NSString
459 if let r = range.firstMatch(in: resolved, range: NSRange(location: 0, length: rns.length)), r.range.length > 1 {
460 return try remote.tableRange(rns.substring(with: r.range), thisline: 0, col: 1, analysis: analysis)
461 }
462 return .field(resolved)
463 }
464
465 /// `org-table-eval-formula` with the equation given, at point. `substituted` when names
466 /// and constants were already replaced, as `org-table-recalculate` does.
467 func tableEvalFormula(_ formula: String, analysis: TableAnalysis, substituted: Bool = true) throws {
279468 var formula = formula
280469 var modes = Calc.Modes()
470 var mode = ReferenceMode()
471 var duration: DurationFormat?
472 var literal = false
281473 var fmt: String?
282474 if let semicolon = formula.lastIndex(of: ";") {
283 var flags = String(formula[formula.index(after: semicolon)...])
475 var flags = (analysis.parameters.last { $0.name == "%" }?.value ?? "") + formula[formula.index(after: semicolon)...]
284476 formula = String(formula[..<semicolon])
285477 let numeric = try! NSRegularExpression(pattern: "([pnfse])(-?[0-9]+)")
286478 while let m = numeric.firstMatch(in: flags, range: NSRange(location: 0, length: (flags as NSString).length)) {
@@ -299,27 +491,32 @@ extension EmacsBuffer {
299491 while let m = letters.firstMatch(in: flags, range: NSRange(location: 0, length: (flags as NSString).length)) {
300492 let ns = flags as NSString
301493 switch ns.substring(with: m.range) {
494 case "t": (duration, mode.numbers) = (.hours, true)
495 case "T": (duration, mode.numbers) = (.hms, true)
496 case "U": (duration, mode.numbers) = (.hhmm, true)
497 case "N": mode.numbers = true
498 case "L": literal = true
499 case "E": mode.keepEmpty = true
302500 case "D": modes.degrees = true
303501 case "R": modes.degrees = false
304502 default: throw Calc.Unsupported("format flag \(ns.substring(with: m.range))")
305503 }
306504 flags = ns.replacingCharacters(in: m.range, with: "")
307505 }
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 }
506 if flags.contains(where: { !$0.isWhitespace }) { fmt = flags }
314507 }
315 if formula.hasPrefix("'(") { throw Calc.Unsupported("Lisp formula") }
316 formula = substituteConstants(formula)
317 if formula.range(of: "\\$[a-zA-Z_]", options: .regularExpression) != nil || formula.contains("remote(")
318 || formula.range(of: "[<\\[][0-9]{4}-", options: .regularExpression) != nil || formula.contains("&") {
319 throw Calc.Unsupported("names, constants, remote references or dates")
508 if !substituted { formula = try Self.handleFirstLast(substituteNames(formula, analysis: analysis), analysis: analysis) }
509 var fields = Self.splitFields(substring(lineStart(point), lineEnd(point)).trimmingCharacters(in: .whitespaces), separator: " *\\| *")
510 if duration != nil { fields = fields.map(Self.timeStringToSeconds) }
511 if mode.numbers { fields = fields.map { $0.contains(where: { !$0.isWhitespace }) ? Self.numberText($0) : $0 } }
512 if formula.count > 2, formula.hasPrefix("'(") { mode.lisp = literal ? .literal : .lisp }
513 func reference(_ value: TableRangeValue) throws -> String {
514 guard duration != nil else { return try Self.makeReference(value, mode) }
515 switch value {
516 case .field(let s): return try Self.makeReference(.field(Self.timeStringToSeconds(s)), mode)
517 case .list(let items): return try Self.makeReference(.list(items.map(Self.timeStringToSeconds)), mode)
518 }
320519 }
321 let line = substring(lineStart(point), lineEnd(point)).trimmingCharacters(in: .whitespaces)
322 let fields = Self.splitFields(line, separator: " *\\| *")
323520 let n0 = tableCurrentColumn()
324521 let thisline = try saveExcursion { () -> Int in
325522 let here = lineStart(point)
@@ -334,10 +531,31 @@ extension EmacsBuffer {
334531 var form = formula
335532 form = form.replacingOccurrences(of: "@#", with: String(tableCurrentDataLine()))
336533 form = form.replacingOccurrences(of: "$#", with: String(n0))
534 // `org-table-remote-reference-indirection`: `remote($1, …)` names the table in a field.
535 let indirect = try! NSRegularExpression(pattern: "\\bremote\\([ \\t]*([@$][^ \\t,]+)[ \\t]*,[ \\t]*([^\\n)]+)\\)")
536 var start = 0
537 while let m = indirect.firstMatch(in: form, range: NSRange(location: start, length: (form as NSString).length - start)) {
538 let ns = form as NSString
539 var eq = try Self.handleFirstLast(ns.substring(with: m.range(at: 1)), analysis: analysis)
540 if eq.range(of: "^\\$[0-9]+$", options: .regularExpression) != nil { eq = "@0" + eq }
541 guard case .field(let name) = try tableRange(eq, thisline: thisline, col: n0, analysis: analysis) else {
542 throw Calc.Unsupported("remote table name from a range")
543 }
544 form = ns.replacingCharacters(in: m.range(at: 1), with: name)
545 start = m.range(at: 1).location + (name as NSString).length
546 }
547 let remote = try! NSRegularExpression(pattern: "\\bremote\\([ \\t]*([^,)]+)[ \\t]*,[ \\t]*([^\\n)]+)\\)")
548 while let m = remote.firstMatch(in: form, range: NSRange(location: 0, length: (form as NSString).length)) {
549 let ns = form as NSString
550 let name = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
551 let value = try remoteRange(name, ns.substring(with: m.range(at: 2)))
552 form = ns.replacingCharacters(in: m.range, with: try reference(value))
553 }
337554 let range = try! NSRegularExpression(pattern: Self.rangePattern)
338555 while let m = range.firstMatch(in: form, range: NSRange(location: 0, length: (form as NSString).length)), m.range.length > 1 {
339556 let ns = form as NSString
340 let replacement = try Self.reference(try tableRange(ns.substring(with: m.range), thisline: thisline, col: n0, analysis: analysis))
557 let replacement = try reference(try tableRange(ns.substring(with: m.range), thisline: thisline, col: n0, analysis: analysis))
558 if replacement.contains(form) { throw UserError(message: "Spreadsheet error: invalid reference \"\(form)\"") }
341559 form = ns.replacingCharacters(in: m.range, with: replacement)
342560 }
343561 let rowRange = try! NSRegularExpression(pattern: "\\$(([-+])?[0-9]+)\\.\\.\\$(([-+])?[0-9]+)")
@@ -346,7 +564,7 @@ extension EmacsBuffer {
346564 let a = Int(ns.substring(with: m.range(at: 1)))! + (m.range(at: 2).location != NSNotFound ? n0 : 0)
347565 let b = Int(ns.substring(with: m.range(at: 3)))! + (m.range(at: 4).location != NSNotFound ? n0 : 0)
348566 guard a >= 1, b <= fields.count, a - 1 <= b else { throw Calc.Unsupported("range outside row") }
349 form = ns.replacingCharacters(in: m.range, with: try Self.reference(.list(Array(fields[(a - 1)..<b]))))
567 form = ns.replacingCharacters(in: m.range, with: try Self.makeReference(.list(Array(fields[(a - 1)..<b])), mode))
350568 }
351569 let single = try! NSRegularExpression(pattern: "\\$(([-+])?[0-9]+)")
352570 while let m = single.firstMatch(in: form, range: NSRange(location: 0, length: (form as NSString).length)) {
@@ -354,37 +572,100 @@ extension EmacsBuffer {
354572 let n = Int(ns.substring(with: m.range(at: 1)))! + (m.range(at: 2).location != NSNotFound ? n0 : 0)
355573 let index = (n == 0 ? n0 : max(n, 1)) - 1
356574 guard index < fields.count else { throw UserError(message: "Invalid field specifier \"\(ns.substring(with: m.range))\"") }
357 form = ns.replacingCharacters(in: m.range, with: try Self.reference(.field(fields[index])))
575 let replacement = try Self.makeReference(.field(fields[index]), mode)
576 if replacement.contains(formula) { throw UserError(message: "Invalid field specifier \"\(ns.substring(with: m.range))\"") }
577 form = ns.replacingCharacters(in: m.range, with: replacement)
578 }
579
580 var result: String
581 if mode.lisp != .none {
582 result = try evalLisp(form, duration: duration)
583 } else {
584 form = form.replacingOccurrences(of: "\\[([0-9]{4}-[0-9]{2}-[0-9]{2}(?: [^\\]>\\n]*)?)\\]", with: "<$1>", options: .regularExpression)
585 form = Self.normalizeTimestamps(form)
586 if duration != nil, form.range(of: "^[0-9]+:[0-9]+(:[0-9]+)?$", options: .regularExpression) != nil {
587 result = form
588 } else {
589 do {
590 result = try Calc.evaluate(form, modes: modes, numeric: !mode.keepEmpty && mode.numbers)
591 } catch is Calc.Failure {
592 (result, fmt) = ("#ERROR", nil)
593 }
594 }
595 if let duration, !result.isEmpty, result != "#ERROR" {
596 let seconds = result.range(of: "^[0-9]+:[0-9]+(:[0-9]+)?$", options: .regularExpression) != nil ? Self.timeStringToSeconds(result) : result
597 result = Self.secondsToString(Elisp.stringToNumber(seconds), duration)
598 }
358599 }
359 var result = try Calc.evaluate(form, modes: modes)
360600 if let fmt {
361 result = String(format: fmt, Self.stringToNumber(result))
601 do {
602 result = try Elisp.format([.string(fmt), Elisp.stringToNumber(result)])
603 } catch let error as Elisp.Signal {
604 throw UserError(message: error.message)
605 }
606 } else {
607 result = result.replacingOccurrences(of: "<([0-9]{4}-[0-9]{2}-[0-9]{2}(?: [^\\]>\\n]*)?)>", with: "[$1]", options: .regularExpression)
362608 }
363609 tableSetField(result)
364610 }
365611
366 /// `org-table-formula-substitute-names` for constants: `$name` from `#+CONSTANTS`, or
367 /// `$PROP_name` from the entry's property, in parentheses; unknown names become
368 /// `#UNDEFINED_NAME`.
369 func substituteConstants(_ formula: String) -> String {
370 guard formula.range(of: "\\$[a-zA-Z]", options: .regularExpression) != nil else { return formula }
371 var constants: [String: String] = [:]
372 for m in (settings.setupKeywords + string).matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) {
373 for pair in m.1.split(whereSeparator: \.isWhitespace) {
374 let parts = pair.split(separator: "=", maxSplits: 1)
375 if parts.count == 2, constants[String(parts[0])] == nil { constants[String(parts[0])] = String(parts[1]) }
612 /// `(eval (eval (read form)))` for a Lisp formula; an error is `#ERROR`.
613 func evalLisp(_ form: String, duration: DurationFormat?) throws -> String {
614 let lisp = Elisp()
615 Self.defineLookups(lisp)
616 let value: Sexp
617 do {
618 value = try lisp.eval(try lisp.eval(try LispReader.readFirst(form).sexp))
619 } catch let unsupported as Elisp.Unsupported {
620 throw Calc.Unsupported("Lisp \(unsupported.what)")
621 } catch {
622 return "#ERROR"
623 }
624 var result = Elisp.printed(value)
625 if let duration { result = Self.secondsToString(Elisp.stringToNumber(result), duration) }
626 return result
627 }
628
629 /// `org-lookup-first`, `org-lookup-last` and `org-lookup-all`.
630 static func defineLookups(_ lisp: Elisp) {
631 func matches(_ lisp: Elisp, _ args: [Sexp]) throws -> [Sexp] {
632 try Elisp.arity(args, 3...4, "org-lookup")
633 let keys = try Elisp.elements(args[1])
634 let values = Elisp.isNil(args[2]) ? keys : try Elisp.elements(args[2])
635 let predicate = args.count > 3 && !Elisp.isNil(args[3]) ? args[3] : .symbol("equal")
636 var found: [Sexp] = []
637 for (i, key) in keys.enumerated() where !Elisp.isNil(try lisp.call(predicate, [args[0], key])) {
638 found.append(i < values.count ? values[i] : .nil)
376639 }
640 return found
377641 }
378 let regex = try! NSRegularExpression(pattern: "\\$([a-zA-Z][_a-zA-Z0-9]*)")
379 var out = formula
380 while let m = regex.firstMatch(in: out, range: NSRange(location: 0, length: (out as NSString).length)) {
642 lisp.define("org-lookup-first") { lisp, args in try matches(lisp, args).first ?? .nil }
643 lisp.define("org-lookup-last") { lisp, args in try matches(lisp, args).last ?? .nil }
644 lisp.define("org-lookup-all") { lisp, args in
645 let found = try matches(lisp, args)
646 return found.isEmpty ? .nil : .list(found)
647 }
648 }
649
650 /// Timestamps in a formula as Calc reads them: `<YYYY-MM-DD Www>`, with ` HH:MM` when
651 /// the stamp has a time, as org formats them for Calc.
652 static func normalizeTimestamps(_ form: String) -> String {
653 let regex = try! NSRegularExpression(pattern: "<([0-9]{4}-[0-9]{2}-[0-9]{2}(?: [^\\]>\\n]*)?)>")
654 var out = form
655 var start = 0
656 while let m = regex.firstMatch(in: out, range: NSRange(location: start, length: (out as NSString).length - start)) {
381657 let ns = out as NSString
382 let name = ns.substring(with: m.range(at: 1))
383 var value = constants[name]
384 if value == nil, name.hasPrefix("PROP_") {
385 value = inheritedProperty(String(name.dropFirst(5)))?.value
658 let stamp = ns.substring(with: m.range(at: 1))
659 let parts = stamp.firstMatch(of: /^([0-9]{4})-([0-9]{2})-([0-9]{2})/)!
660 let day = Days.absolute(year: Int(parts.1)!, month: Int(parts.2)!, day: Int(parts.3)!)
661 let date = Days.date(day)
662 var text = String(format: "<%04d-%02d-%02d %@", date.year, date.month, date.day, Calc.weekdays[Days.weekday(day)])
663 if let time = stamp.firstMatch(of: /([0-9]{1,2}):([0-9]{2})/) {
664 text += String(format: " %02d:%02d", Int(time.1)!, Int(time.2)!)
386665 }
387 out = ns.replacingCharacters(in: m.range, with: "(" + (value ?? "#UNDEFINED_NAME") + ")")
666 text += ">"
667 out = ns.replacingCharacters(in: m.range, with: text)
668 start = m.range.location + (text as NSString).length
388669 }
389670 return out
390671 }
@@ -429,7 +710,7 @@ extension EmacsBuffer {
429710 var columnEquations: [(Int, String)] = []
430711 var fieldEquations: [(String, String)] = []
431712 for (oldLHS, rawRHS) in stored {
432 let rhs = try Self.handleFirstLast(rawRHS, analysis: analysis)
713 let rhs = substituteNames(try Self.handleFirstLast(rawRHS, analysis: analysis), analysis: analysis)
433714 if oldLHS.range(of: "^@-?I+", options: .regularExpression) != nil { throw UserError(message: "Can't assign to hline relative reference") }
434715 let lhs = try Self.handleFirstLast(oldLHS, analysis: analysis)
435716 if lhs.range(of: "^\\$[0-9]+$", options: .regularExpression) != nil {
@@ -443,8 +724,13 @@ extension EmacsBuffer {
443724 for (lhs, rhs) in fieldEquations {
444725 if let m = lhs.firstMatch(of: /^@([0-9]+)\$([0-9]+)$/) {
445726 fields.append((Int(m.1)!, Int(m.2)!, rhs))
446 } else if lhs.range(of: "^@[-+0-9]+\\$-?[0-9]+$", options: .regularExpression) != nil || lhs.range(of: "^[a-zA-Z]", options: .regularExpression) != nil {
447 throw Calc.Unsupported("relative or named field formula")
727 } else if lhs.range(of: "^[a-zA-Z][_a-zA-Z0-9]*$", options: .regularExpression) != nil {
728 guard let location = analysis.namedFields.last(where: { $0.name == lhs }), let row = Self.lineToDline(location.line, analysis) else {
729 throw UserError(message: "Unknown field: \(lhs)")
730 }
731 fields.append((row, location.column, rhs))
732 } else if lhs.range(of: "^@[-+0-9]+\\$-?[0-9]+$", options: .regularExpression) != nil {
733 throw Calc.Unsupported("relative field formula")
448734 } else if let m = lhs.firstMatch(of: /^@([0-9]+)$/) {
449735 for c in 1...analysis.ncol { fields.append((Int(m.1)!, c, rhs)) }
450736 } else {
@@ -463,7 +749,11 @@ extension EmacsBuffer {
463749 let endMarker = marker(end)
464750 defer { release(endMarker) }
465751 var beg: Int
466 if all {
752 var lineRegexp = "^[ \\t]*\\|[^-]"
753 if all, analysis.marked {
754 beg = analysis.begin
755 lineRegexp = "^[ \\t]*\\| *[#*] *(\\||$)"
756 } else if all {
467757 beg = analysis.begin
468758 point = beg
469759 if searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) != nil,
@@ -493,7 +783,7 @@ extension EmacsBuffer {
493783 point = beg
494784 let rowMarker = marker(beg)
495785 defer { release(rowMarker) }
496 while let match = searchForward("^[ \\t]*\\|[^-]", bound: endMarker.position, caseInsensitive: false) {
786 while let match = searchForward(lineRegexp, bound: endMarker.position, caseInsensitive: false) {
497787 _ = match
498788 let rowStart = lineStart(point)
499789 rowMarker.position = rowStart
@@ -713,7 +1003,7 @@ extension EmacsBuffer {
7131003 let analysis = try tableAnalyze()
7141004 let formula = try tableGetFormula(equation, named: named, analysis: analysis)
7151005 do {
716 try tableEvalFormula(try Self.handleFirstLast(formula, analysis: analysis), analysis: analysis)
1006 try tableEvalFormula(formula, analysis: analysis, substituted: false)
7171007 } catch let error as Calc.Unsupported {
7181008 throw UserError(message: "The formula was stored; recalculating it needs Emacs: " + error.reason)
7191009 }
Sources/OrgstarMobile/MobileEditor.swift +2
@@ -172,6 +172,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
172172 onTangle?(scope)
173173 case .external(.agenda(let start, let count)) where onAgenda != nil:
174174 onAgenda?(start, count)
175 case .external(.emacsTable(_, _, _, let reason, _)):
176 onMessage?("This table needs Emacs, which runs on the Mac: \(reason).")
175177 case .external:
176178 onMessage?("\(command.title) needs Emacs, which runs on the Mac.")
177179 }
Tests/OrgCoreTests/CalcTests.swift +10 −1
@@ -53,6 +53,15 @@ struct CalcTests {
5353 "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)",
5454 "(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)",
5555 "vmean([10,20,25])", "(5)-(7.25)", "(-0.001)*(1000)", "1/(3)", "(2)/(3)*(3)",
56 // Dates, nan, comparisons and logic, and the rest of the functions tables use.
57 "<2026-10-05 Mon>+3", "<2026-10-12 Mon>-<2026-10-05 Mon>", "<2026-10-05 Mon 10:00>-<2026-10-05 Mon 08:30>",
58 "<2026-10-05 Mon 08:30>+0.5", "<2026-10-05 Mon 10:00>+1", "<2026-10-05 Mon 08:30>-<2026-10-04 Sun>", "(<2026-12-31 Thu>)+(1)",
59 "<2026-03-01 Sun>-30", "1+<2026-10-05 Mon>", "<2026-10-05 Mon 23:59>-<2026-10-05 Mon 00:01>", "<2026-10-05 Mon 12:00>-0.25",
60 "vmax([<2026-10-05 Mon>,<2026-10-07 Wed>])", "<2026-10-05 Mon> < <2026-10-06 Tue>", "nan", "nan+1", "vsum([1,nan])", "2*nan",
61 "3<4", "3>4", "3<=3", "4>=5", "2==2", "2!=2", "1 && 0", "1 || 0", "!0", "!2", "if(3>4, 1, 2)", "if($1, 7, 8)".replacingOccurrences(of: "$1", with: "0"),
62 "if(1.5, 2.5, 3)", "7%3", "-7%3", "7%-3", "7.5%2", "7\\2", "-7\\2", "7.5\\2", "mod(10,4)", "idiv(-9,4)", "fact(5)", "fact(0)",
63 "vsdev([1,2,3,4])", "vvar([1,2,3])", "vpvar([1,2,3,4])", "vpsdev([2,4,4,4,5,5,7,9])", "vsdev([1.5,2.5,4])",
64 "2+3<4*2", "1<2 && 3<4", "!(1<2)", "(1<2)+(2<3)",
5665 ]
5766
5867 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
@@ -98,7 +107,7 @@ struct CalcTests {
98107 }
99108
100109 @Test func unsupported() {
101 for e in ["abc*2", "1/0", "vmean([])", "pi", "5%", "(-8)^(1/3)", "2 3"] {
110 for e in ["abc*2", "1/0", "vmean([])", "pi", "5%", "(-8)^(1/3)", "2 3", "vvar([5])", "1<2<3"] {
102111 #expect(throws: Calc.Unsupported.self, "\(e)") { try Calc.evaluate(e) }
103112 }
104113 }
Tests/OrgCoreTests/TableFormulaTests.swift +39 −1
@@ -28,6 +28,33 @@ struct TableFormulaTests {
2828 "* Rates\n:PROPERTIES:\n:Rate: 3\n:END:\n| h | pay |\n|---+-----|\n| 2 | |\n#+TBLFM: $2=$1*$PROP_Rate\n",
2929 ]
3030
31 /// Lisp formulas, names, parameters, marked rows, remote tables, durations, dates and
32 /// the rest that once needed Emacs: all run natively.
33 static let native = [
34 "| a | b |\n|---+---|\n| hello | |\n| x \"q\" | |\n| | |\n#+TBLFM: $2='(concat $1 \"!\")\n",
35 "| a | b |\n|---+---|\n| 1 | |\n| 2.5 | |\n| x | |\n#+TBLFM: $2='(* 2 $1);N\n",
36 "| a | b | c |\n|---+---+---|\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3='(+ $1 $2);L\n",
37 "| a |\n|---|\n| 1 |\n| 2 |\n|---|\n| |\n#+TBLFM: @>$1='(apply '+ '(@I..@II));N\n",
38 "| a | b |\n|---+---|\n| 1 | |\n#+TBLFM: $2='(car 1)\n",
39 "| a | b | c |\n|---+---+---|\n| 3.14159 | | |\n| 2 | | |\n#+TBLFM: $2='(format \"%05.1f\" $1);N::$3='(+ $1 1);N%.2f\n",
40 "| a | b |\n|---+---|\n| 5 | |\n| 12 | |\n#+TBLFM: $2='(if (> $1 10) \"big\" \"small\");N\n",
41 "| ! | qty | price | total |\n|---+-----+-------+-------|\n| | 2 | 3 | |\n| | 4 | 0.5 | |\n#+TBLFM: $4=$qty*$price\n",
42 "| | x | y |\n|---+---+---|\n| # | 2 | |\n| | 3 | |\n| * | 4 | |\n| $ | k=10 | |\n#+TBLFM: $3=$2*$k\n",
43 "| | a | b |\n|---+----+----|\n| | 1 | 2 |\n| ^ | aa | bb |\n| _ | | cc |\n| | | |\n#+TBLFM: $cc=$aa+$bb::$2=7\n",
44 "| | a | b |\n|---+----+----|\n| | 1 | 2 |\n| _ | | dd |\n#+TBLFM: $dd=1\n",
45 "| start | end | sum | diff | hours |\n|-------+-----+-----+------+-------|\n| 1:30 | 0:45 | | | |\n| 10:00:30 | 2:15:10 | | | |\n|-------+-----+-----+------+-------|\n| | | | | |\n#+TBLFM: $3=$1+$2;T::$4=$1-$2;U::$5=$1*2;t::@>$1=vsum(@I..@II);T\n",
46 "| d1 | d2 | days | later |\n|----+----+------+-------|\n| <2026-10-05 Mon> | <2026-10-12 Mon> | | |\n| [2026-01-31 Sat] | [2026-03-01 Sun] | | |\n| <2026-10-05 Mon 08:30> | <2026-10-05 Mon 10:00> | | |\n#+TBLFM: $3=$2-$1::$4=$1+7\n",
47 "| d | until |\n|---+-------|\n| <2026-10-05 Mon> | |\n#+TBLFM: $2=<2026-12-25 Fri>-$1\n",
48 "| a | b | c | d |\n|---+---+---+---|\n| 1 | | | |\n| 2 | 3 | | |\n#+TBLFM: $3=$1+$2;E::$4=vcount($1..$2);E\n",
49 "| a | b | c |\n|---+---+---|\n| 5 | 3 | |\n| 2 | 9 | |\n#+TBLFM: $3=if($1>$2, $1, $2)\n",
50 "| a | b | c |\n|---+---+---|\n| 7 | 3 | |\n| -7 | 2 | |\n#+TBLFM: $3=$1%$2+$1\\$2*100\n",
51 "#+NAME: rates\n| item | rate |\n|------+------|\n| a | 2 |\n| b | 3 |\n\n| x | y | z | w |\n|---+---+---+---|\n| 4 | | | |\n#+TBLFM: $2=$1*remote(rates,@2$2)::$3=remote(rates, B3)::$4=vsum(remote(rates,@2$2..@>$2))\n",
52 "#+NAME: prices\n| fruit | price |\n|-------+-------|\n| apple | 1.5 |\n| pear | 2 |\n\n| item | cost |\n|------+------|\n| pear | |\n| plum | |\n#+TBLFM: $2='(org-lookup-first $1 '(remote(prices,@2$1..@>$1)) '(remote(prices,@2$2..@>$2)))\n",
53 "#+CONSTANTS: c=3\n| a | b | c |\n|---+---+---|\n| 2 | | |\n#+TBLFM: $2='(* $c $1);N::$3=$1*$c\n",
54 "| | a | b |\n|---+---+---|\n| | 2 | |\n| | 3 | |\n| $ | %=%.2f | |\n#+TBLFM: $3=$2/3;\n",
55 "| a | b |\n|---+---|\n| 4 | |\n| 9 | |\n#+TBLFM: $2=vsdev(@I$1..@II$1)+fact(3)\n",
56 ]
57
3158 static func carets(_ text: String) -> [Int] {
3259 let ns = text as NSString
3360 var result: [Int] = []
@@ -47,9 +74,18 @@ struct TableFormulaTests {
4774
4875 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
4976 func matchesEmacs() throws {
77 try compare(Self.tables, fallbacksAllowed: true)
78 }
79
80 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
81 func extendedFormulasMatchEmacsNatively() throws {
82 try compare(Self.native, fallbacksAllowed: false)
83 }
84
85 func compare(_ tables: [String], fallbacksAllowed: Bool) throws {
5086 var cases: [EmacsOracle.Case] = []
5187 var ours: [(text: String, caret: Int, failure: String?)] = []
52 for text in Self.tables {
88 for text in tables {
5389 for caret in Self.carets(text) {
5490 let onTblfm = (text as NSString).substring(with: (text as NSString).lineRange(for: NSRange(location: caret, length: 0))).uppercased().contains("#+TBLFM")
5591 let commands: [(any OrgCommand, String)] = onTblfm
@@ -66,6 +102,8 @@ struct TableFormulaTests {
66102 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
67103 if let failure = mine.failure, failure.hasPrefix("external:") {
68104 fallbacks += 1
105 // Calc keeps text as symbols, which goes to Emacs.
106 #expect(fallbacksAllowed || failure.contains("non-numeric field"), "\(failure) in\n\(cases[i].text)")
69107 // Emacs gives symbolic results for text and division by zero.
70108 #expect(failure.contains("non-numeric field") || failure.contains("division by zero"), "\(failure)")
71109 continue