krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Compute/TableFormulas.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/OrgCore/Compute/TableFormulas.swift history · blame · raw

607 lines · 30317 bytes

  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}