| @@ -605,3 +605,203 @@ public struct TableRecalculate: OrgCommand { |
| 605 | 605 | )) |
| 606 | 606 | } |
| 607 | 607 | } |
| 608 | |
| 609 | // Formulas typed in a field (`org-table-maybe-eval-formula`) and C-c = (`org-table-eval-formula`). |
| 610 | extension EmacsBuffer { |
| 611 | /// `org-table-formula-make-cmp-string`. |
| 612 | static func formulaSortKey(_ lhs: String, analysis: TableAnalysis) -> String? { |
| 613 | var a = lhs |
| 614 | if let arrow = a.firstMatch(of: /^\$([<>])/) { |
| 615 | a = (try? handleFirstLast(a, analysis: analysis)) ?? a |
| 616 | a = "$\(10000 + (arrow.1 == "<" ? -1000 : 0) + Int(stringToNumber(String(a.dropFirst()))))" |
| 617 | } |
| 618 | guard let m = a.firstMatch(of: /^(@([0-9]+))?(\$?([0-9]+))?(\$?[a-zA-Z0-9]+)?/) else { return nil } |
| 619 | var key = "" |
| 620 | if let row = m.2 { key += String(format: "@%05d", Int(row) ?? 0) } |
| 621 | if let column = m.4 { key += String(format: "$%05d", Int(column) ?? 0) } |
| 622 | if let name = m.5 { key += "@@" + name } |
| 623 | return key |
| 624 | } |
| 625 | |
| 626 | /// `sort` with `org-table-formula-less-p`. |
| 627 | static func sortFormulas(_ formulas: [(lhs: String, rhs: String)], analysis: TableAnalysis) -> [(lhs: String, rhs: String)] { |
| 628 | var sorted: [(formula: (lhs: String, rhs: String), key: String?)] = [] |
| 629 | // Stable insertion, as `sort` with a partial predicate. |
| 630 | for formula in formulas { |
| 631 | let key = formulaSortKey(formula.lhs, analysis: analysis) |
| 632 | var index = sorted.count |
| 633 | while index > 0, let a = key, let b = sorted[index - 1].key, a < b { index -= 1 } |
| 634 | sorted.insert((formula, key), at: index) |
| 635 | } |
| 636 | return sorted.map(\.formula) |
| 637 | } |
| 638 | |
| 639 | /// `org-table-store-formulas`, sorted by `org-table-formula-less-p`. |
| 640 | func tableStoreFormulas(_ formulas: [(lhs: String, rhs: String)], analysis: TableAnalysis) { |
| 641 | let sorted = Self.sortFormulas(formulas, analysis: analysis) |
| 642 | saveExcursion { |
| 643 | point = tableEnd() |
| 644 | if let match = looking("([ \\t]*\\n)*[ \\t]*(#\\+TBLFM:)(.*\\n?)", caseInsensitive: true, setsMatch: false) { |
| 645 | deleteRegion(match[3]!.lowerBound, match[0]!.upperBound) |
| 646 | point = match[3]!.lowerBound |
| 647 | } else { |
| 648 | insert("#+TBLFM:") |
| 649 | } |
| 650 | insert(" " + sorted.map { $0.lhs + "=" + $0.rhs }.joined(separator: "::") + "\n") |
| 651 | } |
| 652 | } |
| 653 | |
| 654 | /// `org-table-get-formula` with EQUATION given: stores it for the current column, or with |
| 655 | /// `named` the current field, and returns it. |
| 656 | func tableGetFormula(_ equation: String, named: Bool, analysis: TableAnalysis) throws -> String { |
| 657 | var stored = try Self.storedFormulas(firstTblfm(after: tableEnd()) ?? "") |
| 658 | let ref = "@\(tableCurrentDataLine())$\(tableCurrentColumn())" |
| 659 | let scol = named ? ref : "$\(tableCurrentColumn())" |
| 660 | let old = stored.first { $0.lhs == scol }?.rhs |
| 661 | var eq = old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation |
| 662 | guard eq.contains(where: { !$0.isWhitespace }) else { |
| 663 | stored.removeAll { $0.lhs == scol } |
| 664 | tableStoreFormulas(stored, analysis: analysis) |
| 665 | throw UserError(message: "Formula removed") |
| 666 | } |
| 667 | eq = eq.replacingOccurrences(of: "^ *=?", with: "", options: .regularExpression) |
| 668 | eq = eq.replacingOccurrences(of: " *$", with: "", options: .regularExpression) |
| 669 | var mustSave = false |
| 670 | if !named, let index = stored.firstIndex(where: { $0.lhs == ref }) { |
| 671 | stored.remove(at: index) |
| 672 | mustSave = true |
| 673 | } |
| 674 | if let index = stored.firstIndex(where: { $0.lhs == scol }) { |
| 675 | stored[index].rhs = eq |
| 676 | } else { |
| 677 | stored.insert((scol, eq), at: 0) |
| 678 | } |
| 679 | if mustSave || old != eq { tableStoreFormulas(stored, analysis: analysis) } |
| 680 | return eq |
| 681 | } |
| 682 | |
| 683 | /// `org-table-eval-formula` for the current field with EQUATION, which is stored first. |
| 684 | func tableEvalFormula(equation: String, named: Bool) throws { |
| 685 | try checkInsideDataField() |
| 686 | let analysis = try tableAnalyze() |
| 687 | let formula = try tableGetFormula(equation, named: named, analysis: analysis) |
| 688 | do { |
| 689 | try tableEvalFormula(try Self.handleFirstLast(formula, analysis: analysis), analysis: analysis) |
| 690 | } catch let error as Calc.Unsupported { |
| 691 | throw UserError(message: "The formula was stored; recalculating it needs Emacs: " + error.reason) |
| 692 | } |
| 693 | tableAlign() |
| 694 | } |
| 695 | |
| 696 | /// `org-table-maybe-eval-formula`. |
| 697 | func tableMaybeEvalFormula() throws { |
| 698 | skipBackwardNot([124, 10]) |
| 699 | guard !bolp, looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil else { return } |
| 700 | let start = point |
| 701 | var end = point |
| 702 | while let c = character(at: end), c != 124, c != 10, c != 13 { end += 1 } |
| 703 | point = min(lineEnd(point), start + 1) |
| 704 | let field = substring(start, end).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r")) |
| 705 | guard let m = field.firstMatch(of: /^:?=(.*[^=])$/) else { return } |
| 706 | try tableEvalFormula(equation: String(m.1), named: field.hasPrefix(":")) |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | /// C-c = and C-u C-c =: `org-table-eval-formula`, asking for the column's or the field's |
| 711 | /// formula, the stored one offered. |
| 712 | public struct TableEvalFormula: OrgCommand { |
| 713 | let named: Bool |
| 714 | public init(named: Bool) { self.named = named } |
| 715 | public var id: String { named ? "org.table.field-formula" : "org.table.column-formula" } |
| 716 | public var title: String { named ? "Set Field Formula" : "Set Column Formula" } |
| 717 | public func applies(in context: EditContext) -> Bool { KeyContext.holds("table", in: context) } |
| 718 | public func run(in context: EditContext) -> CommandStep { |
| 719 | guard let answer = context.answers["formula"] else { |
| 720 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) |
| 721 | guard buffer.atTable else { return .failed("Not at a table") } |
| 722 | let column = buffer.tableCurrentColumn() |
| 723 | let scol = named ? "@\(buffer.tableCurrentDataLine())$\(column)" : "$\(column)" |
| 724 | let stored = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: buffer.tableEnd()) ?? ""))?.first { $0.lhs == scol }?.rhs |
| 725 | return .prompt(Prompt(key: "formula", message: "\(named ? "Field" : "Column") formula \(scol)=", initial: stored ?? "")) |
| 726 | } |
| 727 | return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named) } |
| 728 | } |
| 729 | } |
| 730 | |
| 731 | // The formula editor (`org-table-edit-formulas`, C-c ' in a table), in the block editor. |
| 732 | extension BlockEdit { |
| 733 | static let formulasKind = "formulas" |
| 734 | |
| 735 | /// The formulas of the table at `offset`, or of the `#+TBLFM` line there, one per line as |
| 736 | /// org's editor lists them. The range runs from the table (or that line) to the formulas. |
| 737 | public static func formulas(at offset: Int, text: String) -> BlockEdit? { |
| 738 | let buffer = EmacsBuffer(text, point: offset) |
| 739 | let line = buffer.substring(buffer.lineStart(offset), buffer.lineEnd(offset)) |
| 740 | let atTblfm = line.range(of: EmacsBuffer.tblfmPattern, options: [.regularExpression, .caseInsensitive]) != nil |
| 741 | guard atTblfm || buffer.atTable else { return nil } |
| 742 | let start: Int |
| 743 | let end: Int |
| 744 | let formulas: [(lhs: String, rhs: String)] |
| 745 | if atTblfm { |
| 746 | start = buffer.lineStart(offset) |
| 747 | end = min(buffer.length, buffer.lineEnd(offset) + 1) |
| 748 | let tblfm = String(line[line.range(of: ":")!.upperBound...]).trimmingCharacters(in: .init(charactersIn: " ")) |
| 749 | formulas = (try? EmacsBuffer.storedFormulas(tblfm)) ?? [] |
| 750 | guard buffer.searchBackward("^[ \\t]*\\|", caseInsensitive: false) != nil else { return nil } |
| 751 | } else { |
| 752 | start = buffer.tableBegin() |
| 753 | let tableEnd = buffer.tableEnd() |
| 754 | end = buffer.saveExcursion { |
| 755 | buffer.point = tableEnd |
| 756 | return buffer.looking("([ \\t]*\\n)*[ \\t]*#\\+TBLFM:.*\\n?", caseInsensitive: true, setsMatch: false)?[0]?.upperBound ?? tableEnd |
| 757 | } |
| 758 | formulas = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: tableEnd) ?? "")) ?? [] |
| 759 | } |
| 760 | guard let analysis = try? buffer.tableAnalyze() else { return nil } |
| 761 | var content = "" |
| 762 | var titles: [(type: String, title: String)] = [("column", "# Column Formulas\n"), ("field", "# Field and Range Formulas\n"), ("named", "# Named Field Formulas\n")] |
| 763 | for (lhs, rhs) in EmacsBuffer.sortFormulas(formulas, analysis: analysis) { |
| 764 | let type = lhs.range(of: "^\\$([0-9]+|[<>]+)$", options: .regularExpression) != nil ? "column" : lhs.hasPrefix("@") ? "field" : "named" |
| 765 | if let index = titles.firstIndex(where: { $0.type == type }) { |
| 766 | if !content.isEmpty { content += "\n" } |
| 767 | content += titles[index].title |
| 768 | titles.remove(at: index) |
| 769 | } |
| 770 | content += (lhs.hasPrefix("@") || lhs.hasPrefix("$") ? "" : "$") + lhs + " = " + rhs + "\n" |
| 771 | } |
| 772 | let ns = text as NSString |
| 773 | return BlockEdit(range: start..<end, original: ns.substring(with: NSRange(location: start, length: end - start)), content: content, language: nil, kind: formulasKind) |
| 774 | } |
| 775 | |
| 776 | /// `org-table-fedit-finish`'s reading of the editor's text. |
| 777 | static func parseFormulas(_ content: String) throws -> [(lhs: String, rhs: String)] { |
| 778 | let regex = try! NSRegularExpression(pattern: "^(@[-+I<>0-9.$@]+|@?[0-9]+|\\$([a-zA-Z0-9]+|[<>]+)) *= *(.*(\\n[ \\t]+.*$)*)", options: .anchorsMatchLines) |
| 779 | let ns = content as NSString |
| 780 | var result: [(lhs: String, rhs: String)] = [] |
| 781 | for m in regex.matches(in: content, range: NSRange(location: 0, length: ns.length)) { |
| 782 | let lhs = ns.substring(with: m.range(at: 1)) |
| 783 | var rhs = ns.substring(with: m.range(at: 3)).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r")) |
| 784 | guard !rhs.isEmpty else { continue } |
| 785 | rhs = rhs.replacingOccurrences(of: "[ \\t]*\\n[ \\t]*", with: " ", options: .regularExpression) |
| 786 | if result.contains(where: { $0.lhs == lhs }) { throw EmacsBuffer.UserError(message: "Double formulas for \(lhs)") } |
| 787 | result.insert((lhs, rhs), at: 0) |
| 788 | } |
| 789 | return result |
| 790 | } |
| 791 | |
| 792 | /// Installs edited formulas, as `org-table-fedit-finish` without a prefix argument. |
| 793 | func installFormulas(_ content: String, in context: EditContext) -> CommandStep { |
| 794 | let step = commitBuffer(context) { buffer in |
| 795 | let formulas = try Self.parseFormulas(content) |
| 796 | buffer.point = range.lowerBound |
| 797 | let analysis = try buffer.saveExcursion { () throws -> EmacsBuffer.TableAnalysis in |
| 798 | if !buffer.atTable { _ = buffer.searchBackward("^[ \\t]*\\|", caseInsensitive: false) } |
| 799 | return try buffer.tableAnalyze() |
| 800 | } |
| 801 | buffer.tableStoreFormulas(formulas, analysis: analysis) |
| 802 | } |
| 803 | guard case .commit(let result) = step else { return step } |
| 804 | return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, |
| 805 | effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")])) |
| 806 | } |
| 807 | } |