Table formulas typed in fields, C-c =, formula editor !84

merged merged by cmc on 2026-10-06 03:56 UTC · krz/orgstar:table-formula-entry into main

8 files changed, +288 −5

Layout: unified · split

Sources/OrgCore/Commands/BlockCommands.swift +8 −2
@@ -162,9 +162,14 @@ public struct EditSpecial: OrgCommand {
162162 public init() {}
163163 public var id: String { "org.edit-special" }
164164 public var title: String { "Edit Block" }
165 public func applies(in context: EditContext) -> Bool { BlockEdit.at(context.caret, text: context.text, tree: context.tree) != nil }
165 public func applies(in context: EditContext) -> Bool { edit(context) != nil }
166
167 private func edit(_ context: EditContext) -> BlockEdit? {
168 BlockEdit.at(context.caret, text: context.text, tree: context.tree) ?? BlockEdit.formulas(at: context.caret, text: context.text)
169 }
170
166171 public func run(in context: EditContext) -> CommandStep {
167 guard let edit = BlockEdit.at(context.caret, text: context.text, tree: context.tree) else {
172 guard let edit = edit(context) else {
168173 return .failed("No special environment to edit here")
169174 }
170175 return .external(.editBlock(edit))
@@ -187,6 +192,7 @@ public struct ReplaceBlockLines: OrgCommand {
187192 guard edit.range.upperBound <= ns.length, ns.substring(with: NSRange(edit.range)) == edit.original else {
188193 return .failed("The block changed while you edited it; its edit wasn't saved.")
189194 }
195 if edit.kind == BlockEdit.formulasKind { return edit.installFormulas(content, in: context) }
190196 let lines = edit.lines(for: content)
191197 return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)]))
192198 }
Sources/OrgCore/Commands/Command.swift +1 −1
@@ -232,7 +232,7 @@ public enum Commands {
232232 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
233233 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
234234 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
235 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true),
235 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true),
236236 ]
237237
238238 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/CtrlCCtrlC.swift +4 −1
@@ -20,7 +20,10 @@ public struct CtrlCCtrlC: OrgCommand {
2020 if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil {
2121 // At the table's very start the context is the table itself, which org recalculates.
2222 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
23 return TableRecalculate.tblfmLine(in: context) != nil || atStart ? TableRecalculate(all: true).run(in: context) : TableCommand(.align).run(in: context)
23 return TableRecalculate.tblfmLine(in: context) != nil || atStart ? TableRecalculate(all: true).run(in: context) : commitBuffer(context) { buffer in
24 try buffer.tableMaybeEvalFormula()
25 buffer.tableAlign()
26 }
2427 }
2528 if buffer.footnoteReferenceAtPoint() != nil
2629 || buffer.footnoteDefinitions().contains(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) {
Sources/OrgCore/Commands/Table.swift +2
@@ -283,6 +283,7 @@ extension EmacsBuffer {
283283
284284 /// `org-table-next-field`.
285285 func tableNextField() throws {
286 try tableMaybeEvalFormula()
286287 moveIntoField()
287288 tableAlign()
288289 let end = tableEnd()
@@ -327,6 +328,7 @@ extension EmacsBuffer {
327328
328329 /// `org-table-next-row`.
329330 func tableNextRow() throws {
331 try tableMaybeEvalFormula()
330332 moveIntoField()
331333 tableAlign()
332334 let column = tableCurrentColumn()
Sources/OrgCore/Compute/TableFormulas.swift +200
@@ -605,3 +605,203 @@ public struct TableRecalculate: OrgCommand {
605605 ))
606606 }
607607}
608
609// Formulas typed in a field (`org-table-maybe-eval-formula`) and C-c = (`org-table-eval-formula`).
610extension 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.
712public 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.
732extension 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}
Sources/OrgCore/Keymap/Presets.swift +3
@@ -644,6 +644,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
644644 keys = "C-c C-x f"
645645 command = "org.footnote.action"
646646 [[bind]]
647 keys = "C-c ="
648 command = "org.table.column-formula"
649 [[bind]]
647650 keys = "C-c C-x C-r"
648651 command = "org.clock.report"
649652 [[bind]]
Sources/Orgstar/BlockEditorView.swift +1 −1
@@ -13,7 +13,7 @@ struct BlockEditorView: View {
1313
1414 var body: some View {
1515 VStack(alignment: .leading, spacing: 8) {
16 Text("Editing \(edit.language.map { "\($0) " } ?? "")\(edit.kind) block")
16 Text(edit.kind == "formulas" ? "Editing table formulas" : "Editing \(edit.language.map { "\($0) " } ?? "")\(edit.kind) block")
1717 .font(.headline)
1818 CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) }
1919 .frame(minWidth: 560, minHeight: 320)
Tests/OrgCoreTests/TableFormulaEntryTests.swift added +69
@@ -0,0 +1,69 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TableFormulaEntryTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 @Test(.enabled(if: oracle))
9 func evaluatesTypedFormulasLikeOrg() throws {
10 let plain = "| a | b |\n|---+---|\n| 1 | =$1*2 |\n| 3 | |\nAfter\n"
11 let named = "| a | b |\n|---+---|\n| 1 | :=$1+10 |\n| 3 | |\nAfter\n"
12 let stored = "| a | b | c |\n|---+---+---|\n| 1 | 2 | =$1+$2 |\n| 3 | 4 | |\n#+TBLFM: @2$1=7::$2=$1*2\n"
13 let replaced = "| a | b |\n| 1 | =$1*5 |\n#+TBLFM: $2=$1*2::@1$2=x\n"
14 let reuse = "| a | b |\n| 2 | = |\n#+TBLFM: $2=$1*2\n\nText\n"
15 let last = "| 1 | =$1+1 |\n"
16 let cases: [(String, String, any OrgCommand, [String: String], String)] = [
17 (plain, "*2", TableCommand(.nextField), [:], "(org-table-next-field)"),
18 (plain, "*2", TableCommand(.nextRow), [:], "(org-table-next-row)"),
19 (plain, "*2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
20 (named, "+10", TableCommand(.nextField), [:], "(org-table-next-field)"),
21 (stored, "+$2", TableCommand(.nextField), [:], "(org-table-next-field)"),
22 (replaced, "*5", TableCommand(.nextField), [:], "(org-table-next-field)"),
23 (reuse, "= ", TableCommand(.nextField), [:], "(org-table-next-field)"),
24 (last, "+1", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
25 (stored, "| 3", TableEvalFormula(named: false), ["formula": "$1*3"], "(org-table-eval-formula nil \"$1*3\")"),
26 (stored, "| 3", TableEvalFormula(named: true), ["formula": "99"], "(org-table-eval-formula '(4) \"99\")"),
27 (stored, "| 1 | 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
28 ]
29 let emacs = try EmacsOracle.run(cases.map { c in
30 let caret = NSMaxRange((c.0 as NSString).range(of: c.1))
31 return EmacsOracle.Case(text: c.0, point: EmacsOracle.point(caret, in: c.0), form: c.4)
32 })
33 for (c, theirs) in zip(cases, emacs) {
34 let caret = NSMaxRange((c.0 as NSString).range(of: c.1))
35 let ours = runCommand(c.2, c.0, caret: caret, answers: c.3)
36 let theirCaret = EmacsOracle.offset(theirs.point, in: theirs.text)
37 #expect(ours.text == theirs.text && ours.caret == theirCaret && ours.failure == nil,
38 "\(c.4) at \(c.1)\nours: \(ours.text.debugDescription) @\(ours.caret) \(ours.failure ?? "")\nemacs: \(theirs.text.debugDescription) @\(theirCaret) \(theirs.error)")
39 }
40 }
41}
42
43extension TableFormulaEntryTests {
44 @Test(.enabled(if: oracle))
45 func editsFormulasLikeOrg() throws {
46 let text = "| a | b | c |\n|---+---+---|\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |\n#+TBLFM: @3$1=7::$3=$1+$2::$>=$1::@2$2=vsum(@2..@3)::$2=$1*2\n"
47 let inTable = (text as NSString).range(of: "| 5").location + 2
48 let onTblfm = (text as NSString).range(of: "#+TBLFM").location + 3
49 for offset in [inTable, onTblfm] {
50 let point = EmacsOracle.point(offset, in: text)
51 let emacs = try EmacsOracle.evaluate(text, "(list (with-current-buffer (find-file-noselect \"oracle.org\") (goto-char \(point)) (org-table-edit-formulas) (buffer-substring-no-properties (point-min) (point-max))))")
52 #expect(BlockEdit.formulas(at: offset, text: text)?.content == emacs.first)
53 }
54 let edited = "# Column Formulas\n$2 = $1 * 3\n$3 = $1+\n $2\n\n@2$2 = 9\n$> = \n"
55 let cases = [inTable, onTblfm].map { offset in
56 EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: """
57 (let ((b (current-buffer)))
58 (org-table-edit-formulas) (erase-buffer) (insert \(lispString(edited)))
59 (cl-letf (((symbol-function 'set-window-configuration) (lambda (&rest _) (set-buffer b))) ((symbol-function 'select-window) #'ignore)) (org-table-fedit-finish))
60 (set-buffer b))
61 """)
62 }
63 for (offset, theirs) in zip([inTable, onTblfm], try EmacsOracle.run(cases)) {
64 let edit = try #require(BlockEdit.formulas(at: offset, text: text))
65 let ours = runCommand(ReplaceBlockLines(edit: edit, content: edited), text, caret: offset)
66 #expect(ours.text == theirs.text, "ours: \(ours.text.debugDescription) \(ours.failure ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
67 }
68 }
69}