krz/orgstar

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

Commit ff45d11a82

ff45d11a825c841d4bed1bb5bec9949ec53be893

parent: 433d982dfa

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 23:19 UTC

Find remote tables by ID; relative formula targets error as in Org

remote(ID, ...) finds the first table in the entry with that ID, in
the file or, through the index, another one. A field formula whose
target is relative (@-1$2=) fails with "Unknown field", as
org-table-goto-field does, rather than going to Emacs.

Layout: unified · split

Sources/OrgApp/WorkspaceModel.swift +6
@@ -55,6 +55,12 @@ public final class WorkspaceModel {
5555 }
5656 resolveRoots()
5757 try reloadFiles()
58 // `remote()` in table formulas reaches tables in other files by ID.
59 let index = index
60 RemoteTables.fileWithID = { (id: String) -> String? in
61 guard let path = (try? index.headings(withID: id))?.first?.path else { return nil }
62 return FileManager.default.contents(atPath: path).map { SourceText(bytes: [UInt8]($0)).text }
63 }
5864 }
5965
6066 /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development).
Sources/OrgCore/Compute/TableFormulas.swift +36 −10
@@ -5,6 +5,12 @@ import Foundation
55// features beyond `Calc`, Lisp beyond `Elisp`, remote tables found by ID and relative field
66// targets throw `Calc.Unsupported`; the caller then recalculates the table in Emacs.
77
8/// Where `remote()` finds tables by ID in other files.
9public enum RemoteTables {
10 /// The text of the file holding the entry with an ID; the app sets it, from its index.
11 nonisolated(unsafe) public static var fileWithID: ((String) -> String?)?
12}
13
814extension EmacsBuffer {
915 struct TableAnalysis {
1016 /// Table-relative line index of each data line, from 1 (index 0 unused).
@@ -452,21 +458,40 @@ extension EmacsBuffer {
452458 return new
453459 }
454460
455 /// `org-table-get-remote-range`: the table after `#+NAME: name` in this file.
461 /// `org-table-get-remote-range`: the table after `#+NAME: name` in this file, or the first
462 /// table in the entry with that ID, in this file or another.
456463 func remoteRange(_ name: String, _ form: String) throws -> TableRangeValue {
457 let ns = string as NSString
458 let pattern = "^[ \\t]*#\\+(tbl)?name:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$"
459 let keyword = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines, .caseInsensitive])
460 guard let found = keyword.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else {
461 throw Calc.Unsupported("remote table \(name) by ID")
464 let escaped = NSRegularExpression.escapedPattern(for: name)
465 let keyword = try! NSRegularExpression(pattern: "^[ \\t]*#\\+(tbl)?name:[ \\t]*" + escaped + "[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
466 let idLine = try! NSRegularExpression(pattern: "^[ \\t]*:ID:[ \\t]+" + escaped + "[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
467 func first(_ regex: NSRegularExpression, in text: String) -> Int? {
468 regex.firstMatch(in: text, range: NSRange(location: 0, length: (text as NSString).length))?.range.location
469 }
470 // `org-id-find`: the entry's heading, here or in the file holding the ID.
471 func heading(_ text: String, _ offset: Int) -> Int {
472 let before = try! NSRegularExpression(pattern: "^\\*+ ", options: .anchorsMatchLines)
473 return before.matches(in: text, range: NSRange(location: 0, length: offset)).last?.range.location ?? 0
474 }
475 var text = string
476 let location: Int
477 if let found = first(keyword, in: string) {
478 location = found
479 } else if let found = first(idLine, in: string) {
480 location = heading(string, found)
481 } else if let other = RemoteTables.fileWithID?(name), let found = first(idLine, in: other) {
482 text = other
483 location = heading(other, found)
484 } else {
485 throw UserError(message: "Can't find remote table \"\(name)\"")
462486 }
463 let after = min(ns.length, found.range.location + 1)
487 let ns = text as NSString
488 let after = min(ns.length, location + 1)
464489 let next = try! NSRegularExpression(pattern: "^(\\*+ )|^[ \\t]*\\|", options: .anchorsMatchLines)
465 guard let m = next.firstMatch(in: string, range: NSRange(location: after, length: ns.length - after)),
490 guard let m = next.firstMatch(in: text, range: NSRange(location: after, length: ns.length - after)),
466491 m.range(at: 1).location == NSNotFound else {
467492 throw UserError(message: "Cannot find a table at NAME or ID \(name)")
468493 }
469 let remote = EmacsBuffer(string, point: m.range.location, settings: settings, options: options)
494 let remote = EmacsBuffer(text, point: m.range.location, settings: settings, options: options)
470495 let analysis = try remote.tableAnalyze()
471496 let resolved = try remote.substituteNames(try Self.handleFirstLast(Self.refsToRC(form), analysis: analysis), analysis: analysis)
472497 let range = try! NSRegularExpression(pattern: Self.rangePattern)
@@ -745,7 +770,8 @@ extension EmacsBuffer {
745770 }
746771 fields.append((row, location.column, rhs))
747772 } else if lhs.range(of: "^@[-+0-9]+\\$-?[0-9]+$", options: .regularExpression) != nil {
748 throw Calc.Unsupported("relative field formula")
773 // `org-table-goto-field` knows only absolute fields and names.
774 throw UserError(message: "Unknown field: \(lhs)")
749775 } else if let m = lhs.firstMatch(of: /^@([0-9]+)$/) {
750776 let row = try Self.integer(String(m.1))
751777 for c in 1...analysis.ncol { fields.append((row, c, rhs)) }
Tests/OrgCoreTests/TableFormulaTests.swift +17 −1
@@ -53,8 +53,23 @@ struct TableFormulaTests {
5353 "#+CONSTANTS: c=3\n| a | b | c |\n|---+---+---|\n| 2 | | |\n#+TBLFM: $2='(* $c $1);N::$3=$1*$c\n",
5454 "| | a | b |\n|---+---+---|\n| | 2 | |\n| | 3 | |\n| $ | %=%.2f | |\n#+TBLFM: $3=$2/3;\n",
5555 "| a | b |\n|---+---|\n| 4 | |\n| 9 | |\n#+TBLFM: $2=vsdev(@I$1..@II$1)+fact(3)\n",
56 "| a | b |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: @-1$2=5\n",
5657 ]
5758
59 @Test func remoteFindsTablesByIDInOtherFiles() {
60 let other = "* Elsewhere\n:PROPERTIES:\n:ID: far\n:END:\n| v |\n|---|\n| 7 |\n"
61 RemoteTables.fileWithID = { $0 == "far" ? other : nil }
62 defer { RemoteTables.fileWithID = nil }
63 let text = "| x | y |\n|---+---|\n| 3 | |\n#+TBLFM: $2=$1*remote(far,@2$1)\n"
64 let result = runCommand(TableRecalculate(all: true), text, caret: (text as NSString).range(of: "#+TBLFM").location + 3)
65 #expect(result.text.contains("| 3 | 21 |"), "\(result)")
66 // As Emacs does in a file buffer (`org-id-find` needs one): the entry's first table.
67 let local = "* Rates\n:PROPERTIES:\n:ID: rates-id\n:END:\n| item | rate |\n|------+------|\n| a | 2 |\n* Use\n| x | y |\n|---+---|\n| 4 | |\n#+TBLFM: $2=$1*remote(rates-id,@2$2)\n"
68 #expect(runCommand(TableRecalculate(all: true), local, caret: (local as NSString).range(of: "#+TBLFM").location + 3).text.contains("| 4 | 8 |"))
69 let missing = runCommand(TableRecalculate(all: true), text.replacingOccurrences(of: "far", with: "nowhere"), caret: 2)
70 #expect(missing.failure == "Can't find remote table \"nowhere\"")
71 }
72
5873 static func carets(_ text: String) -> [Int] {
5974 let ns = text as NSString
6075 var result: [Int] = []
@@ -110,7 +125,8 @@ struct TableFormulaTests {
110125 }
111126 if !theirs.error.isEmpty {
112127 // Emacs keeps the fields written before the error; nothing changes here.
113 #expect(mine.failure == theirs.error && mine.text == cases[i].text)
128 #expect(mine.failure == theirs.error && mine.text == cases[i].text,
129 "\(cases[i].form) at \(cases[i].point): ours \(mine.failure ?? "-"), emacs \(theirs.error)\n\(cases[i].text)")
114130 continue
115131 }
116132 let expected = (theirs.text, EmacsOracle.offset(theirs.point, in: theirs.text), theirs.error.isEmpty ? nil : theirs.error)