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

merged merged by cmc on 2026-10-07 23:19 UTC · krz/orgstar:remote-by-id into main

3 files changed, +59 −11

Layout: unified · split

Sources/OrgApp/WorkspaceModel.swift +6
@@ -55,6 +55,12 @@ public final class WorkspaceModel {
55 } 55 }
56 resolveRoots() 56 resolveRoots()
57 try reloadFiles() 57 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 }
58 } 64 }
59 65
60 /// `~/Library/Application Support/Orgstar`, or `ORGSTAR_DATA_DIR` when set (for development). 66 /// `~/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
5// features beyond `Calc`, Lisp beyond `Elisp`, remote tables found by ID and relative field 5// features beyond `Calc`, Lisp beyond `Elisp`, remote tables found by ID and relative field
6// targets throw `Calc.Unsupported`; the caller then recalculates the table in Emacs. 6// targets throw `Calc.Unsupported`; the caller then recalculates the table in Emacs.
7 7
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
8extension EmacsBuffer { 14extension EmacsBuffer {
9 struct TableAnalysis { 15 struct TableAnalysis {
10 /// Table-relative line index of each data line, from 1 (index 0 unused). 16 /// Table-relative line index of each data line, from 1 (index 0 unused).
@@ -452,21 +458,40 @@ extension EmacsBuffer {
452 return new 458 return new
453 } 459 }
454 460
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.
456 func remoteRange(_ name: String, _ form: String) throws -> TableRangeValue { 463 func remoteRange(_ name: String, _ form: String) throws -> TableRangeValue {
457 let ns = string as NSString 464 let escaped = NSRegularExpression.escapedPattern(for: name)
458 let pattern = "^[ \\t]*#\\+(tbl)?name:[ \\t]*" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]*$" 465 let keyword = try! NSRegularExpression(pattern: "^[ \\t]*#\\+(tbl)?name:[ \\t]*" + escaped + "[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
459 let keyword = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines, .caseInsensitive]) 466 let idLine = try! NSRegularExpression(pattern: "^[ \\t]*:ID:[ \\t]+" + escaped + "[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
460 guard let found = keyword.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else { 467 func first(_ regex: NSRegularExpression, in text: String) -> Int? {
461 throw Calc.Unsupported("remote table \(name) by ID") 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)\"")
462 } 486 }
463 let after = min(ns.length, found.range.location + 1) 487 let ns = text as NSString
488 let after = min(ns.length, location + 1)
464 let next = try! NSRegularExpression(pattern: "^(\\*+ )|^[ \\t]*\\|", options: .anchorsMatchLines) 489 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)),
466 m.range(at: 1).location == NSNotFound else { 491 m.range(at: 1).location == NSNotFound else {
467 throw UserError(message: "Cannot find a table at NAME or ID \(name)") 492 throw UserError(message: "Cannot find a table at NAME or ID \(name)")
468 } 493 }
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)
470 let analysis = try remote.tableAnalyze() 495 let analysis = try remote.tableAnalyze()
471 let resolved = try remote.substituteNames(try Self.handleFirstLast(Self.refsToRC(form), analysis: analysis), analysis: analysis) 496 let resolved = try remote.substituteNames(try Self.handleFirstLast(Self.refsToRC(form), analysis: analysis), analysis: analysis)
472 let range = try! NSRegularExpression(pattern: Self.rangePattern) 497 let range = try! NSRegularExpression(pattern: Self.rangePattern)
@@ -745,7 +770,8 @@ extension EmacsBuffer {
745 } 770 }
746 fields.append((row, location.column, rhs)) 771 fields.append((row, location.column, rhs))
747 } else if lhs.range(of: "^@[-+0-9]+\\$-?[0-9]+$", options: .regularExpression) != nil { 772 } 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)")
749 } else if let m = lhs.firstMatch(of: /^@([0-9]+)$/) { 775 } else if let m = lhs.firstMatch(of: /^@([0-9]+)$/) {
750 let row = try Self.integer(String(m.1)) 776 let row = try Self.integer(String(m.1))
751 for c in 1...analysis.ncol { fields.append((row, c, rhs)) } 777 for c in 1...analysis.ncol { fields.append((row, c, rhs)) }
Tests/OrgCoreTests/TableFormulaTests.swift +17 −1
@@ -53,8 +53,23 @@ struct TableFormulaTests {
53 "#+CONSTANTS: c=3\n| a | b | c |\n|---+---+---|\n| 2 | | |\n#+TBLFM: $2='(* $c $1);N::$3=$1*$c\n", 53 "#+CONSTANTS: c=3\n| a | b | c |\n|---+---+---|\n| 2 | | |\n#+TBLFM: $2='(* $c $1);N::$3=$1*$c\n",
54 "| | a | b |\n|---+---+---|\n| | 2 | |\n| | 3 | |\n| $ | %=%.2f | |\n#+TBLFM: $3=$2/3;\n", 54 "| | a | b |\n|---+---+---|\n| | 2 | |\n| | 3 | |\n| $ | %=%.2f | |\n#+TBLFM: $3=$2/3;\n",
55 "| a | b |\n|---+---|\n| 4 | |\n| 9 | |\n#+TBLFM: $2=vsdev(@I$1..@II$1)+fact(3)\n", 55 "| 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",
56 ] 57 ]
57 58
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
58 static func carets(_ text: String) -> [Int] { 73 static func carets(_ text: String) -> [Int] {
59 let ns = text as NSString 74 let ns = text as NSString
60 var result: [Int] = [] 75 var result: [Int] = []
@@ -110,7 +125,8 @@ struct TableFormulaTests {
110 } 125 }
111 if !theirs.error.isEmpty { 126 if !theirs.error.isEmpty {
112 // Emacs keeps the fields written before the error; nothing changes here. 127 // 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)")
114 continue 130 continue
115 } 131 }
116 let expected = (theirs.text, EmacsOracle.offset(theirs.point, in: theirs.text), theirs.error.isEmpty ? nil : theirs.error) 132 let expected = (theirs.text, EmacsOracle.offset(theirs.point, in: theirs.text), theirs.error.isEmpty ? nil : theirs.error)