Commit f07b4417c0
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/BlockCommands.swift +8 −2
| @@ -162,9 +162,14 @@ public struct EditSpecial: OrgCommand { | |||
| 162 | public init() {} | 162 | public init() {} |
| 163 | public var id: String { "org.edit-special" } | 163 | public var id: String { "org.edit-special" } |
| 164 | public var title: String { "Edit Block" } | 164 | 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 | |||
| 166 | public func run(in context: EditContext) -> CommandStep { | 171 | 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 { |
| 168 | return .failed("No special environment to edit here") | 173 | return .failed("No special environment to edit here") |
| 169 | } | 174 | } |
| 170 | return .external(.editBlock(edit)) | 175 | return .external(.editBlock(edit)) |
| @@ -187,6 +192,7 @@ public struct ReplaceBlockLines: OrgCommand { | |||
| 187 | guard edit.range.upperBound <= ns.length, ns.substring(with: NSRange(edit.range)) == edit.original else { | 192 | guard edit.range.upperBound <= ns.length, ns.substring(with: NSRange(edit.range)) == edit.original else { |
| 188 | return .failed("The block changed while you edited it; its edit wasn't saved.") | 193 | return .failed("The block changed while you edited it; its edit wasn't saved.") |
| 189 | } | 194 | } |
| 195 | if edit.kind == BlockEdit.formulasKind { return edit.installFormulas(content, in: context) } | ||
| 190 | let lines = edit.lines(for: content) | 196 | let lines = edit.lines(for: content) |
| 191 | return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)])) | 197 | return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)])) |
| 192 | } | 198 | } |
Sources/OrgCore/Commands/Command.swift +1 −1
| @@ -232,7 +232,7 @@ public enum Commands { | |||
| 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), | 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 234 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), | 234 | 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), |
| 236 | ] | 236 | ] |
| 237 | 237 | ||
| 238 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } | 238 | 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 { | |||
| 20 | if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil { | 20 | if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil { |
| 21 | // At the table's very start the context is the table itself, which org recalculates. | 21 | // At the table's very start the context is the table itself, which org recalculates. |
| 22 | let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret | 22 | 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 | } | ||
| 24 | } | 27 | } |
| 25 | if buffer.footnoteReferenceAtPoint() != nil | 28 | if buffer.footnoteReferenceAtPoint() != nil |
| 26 | || buffer.footnoteDefinitions().contains(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) { | 29 | || 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 { | |||
| 283 | 283 | ||
| 284 | /// `org-table-next-field`. | 284 | /// `org-table-next-field`. |
| 285 | func tableNextField() throws { | 285 | func tableNextField() throws { |
| 286 | try tableMaybeEvalFormula() | ||
| 286 | moveIntoField() | 287 | moveIntoField() |
| 287 | tableAlign() | 288 | tableAlign() |
| 288 | let end = tableEnd() | 289 | let end = tableEnd() |
| @@ -327,6 +328,7 @@ extension EmacsBuffer { | |||
| 327 | 328 | ||
| 328 | /// `org-table-next-row`. | 329 | /// `org-table-next-row`. |
| 329 | func tableNextRow() throws { | 330 | func tableNextRow() throws { |
| 331 | try tableMaybeEvalFormula() | ||
| 330 | moveIntoField() | 332 | moveIntoField() |
| 331 | tableAlign() | 333 | tableAlign() |
| 332 | let column = tableCurrentColumn() | 334 | let column = tableCurrentColumn() |
Sources/OrgCore/Compute/TableFormulas.swift +200
| @@ -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 | } | ||
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -644,6 +644,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 644 | keys = "C-c C-x f" | 644 | keys = "C-c C-x f" |
| 645 | command = "org.footnote.action" | 645 | command = "org.footnote.action" |
| 646 | [[bind]] | 646 | [[bind]] |
| 647 | keys = "C-c =" | ||
| 648 | command = "org.table.column-formula" | ||
| 649 | [[bind]] | ||
| 647 | keys = "C-c C-x C-r" | 650 | keys = "C-c C-x C-r" |
| 648 | command = "org.clock.report" | 651 | command = "org.clock.report" |
| 649 | [[bind]] | 652 | [[bind]] |
Sources/Orgstar/BlockEditorView.swift +1 −1
| @@ -13,7 +13,7 @@ struct BlockEditorView: View { | |||
| 13 | 13 | ||
| 14 | var body: some View { | 14 | var body: some View { |
| 15 | VStack(alignment: .leading, spacing: 8) { | 15 | 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") |
| 17 | .font(.headline) | 17 | .font(.headline) |
| 18 | CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) } | 18 | CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) } |
| 19 | .frame(minWidth: 560, minHeight: 320) | 19 | .frame(minWidth: 560, minHeight: 320) |
Tests/OrgCoreTests/TableFormulaEntryTests.swift added +69
| @@ -0,0 +1,69 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct 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 | |||
| 43 | extension 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 | } | ||