Table and link fixes from the manual review !160

merged merged by cmc on 2026-10-08 04:03 UTC · krz/orgstar:table-link-fixes into main

20 files changed, +360 −57

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +2
@@ -847,6 +847,8 @@ public final class DocumentSession {
847847 let offset: Int
848848 if !isDirty, hash == location.contentHash {
849849 offset = location.start
850 } else if location.ordinal == HeadingLocation.fileOrdinal {
851 offset = 0
850852 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
851853 offset = outline[location.ordinal].start
852854 } else if let entry = outline.first(where: { $0.title == location.title }) {
Sources/OrgApp/LinkFollow.swift +22 −3
@@ -12,8 +12,9 @@ public enum LinkFollow: Equatable, Sendable {
1212
1313extension DocumentSession {
1414 /// Follows a link as `org-link-open` does: files open as buffers (with a `::` search),
15 /// `id:` links through the index, internal links in the current file, and web and mail
16 /// links go to their apps.
15 /// `id:` links through the index, internal and radio links in the current file,
16 /// `attachment:` links in their entry's attachment folder, and web and mail links go to
17 /// their apps.
1718 public func follow(_ link: OrgLink, workspace: WorkspaceModel?, isText: (String) -> Bool) -> LinkFollow {
1819 switch link.type {
1920 case "http", "https", "ftp", "mailto", "news":
@@ -32,6 +33,15 @@ extension DocumentSession {
3233 return searchCurrent("#" + link.path)
3334 case "fuzzy":
3435 return searchCurrent(link.path)
36 case "radio":
37 guard let document else { return .message("No file is open") }
38 return shown(Links.searchRadio(link.path, tree: document.tree))
39 case "attachment":
40 guard let tree = document?.tree,
41 let file = Links.attachmentFile(link, tree: tree, path: path, isDirectory: Self.isDirectory) else {
42 return .message("Need absolute `org-attach-id-dir' to attach in buffers without filename")
43 }
44 return followFile(file, isText: isText)
3545 case "coderef":
3646 return .message("Coderef links aren't supported")
3747 case "shell", "elisp":
@@ -73,9 +83,18 @@ extension DocumentSession {
7383 return searchCurrent(option)
7484 }
7585
86 static func isDirectory(_ path: String) -> Bool {
87 var directory: ObjCBool = false
88 return FileManager.default.fileExists(atPath: path, isDirectory: &directory) && directory.boolValue
89 }
90
7691 private func searchCurrent(_ s: String) -> LinkFollow {
7792 guard let document else { return .message("No file is open") }
78 switch Links.search(s, text: document.text, tree: document.tree) {
93 return shown(Links.search(s, text: document.text, tree: document.tree))
94 }
95
96 private func shown(_ result: Links.SearchResult) -> LinkFollow {
97 switch result {
7998 case .found(let offset):
8099 reveal(offset)
81100 return .shown
Sources/OrgCore/Commands/CtrlCCtrlC.swift +13 −3
@@ -3,7 +3,8 @@ import Foundation
33// C-c C-c (`org-ctrl-c-ctrl-c`, Org 9.8.7) for the contexts no more specific binding takes:
44// headings set tags, clock lines get their duration again, timestamps their weekday, cookies
55// update, items toggle their checkbox, or without one have their list repaired, setup keywords
6// refresh, and elsewhere there's nothing to do.
6// refresh, tables evaluate a typed formula, then align or recalculate a row marked `#`, and
7// elsewhere there's nothing to do.
78
89/// `org-ctrl-c-ctrl-c`.
910public struct CtrlCCtrlC: OrgCommand {
@@ -20,10 +21,19 @@ public struct CtrlCCtrlC: OrgCommand {
2021 if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil {
2122 // At the table's very start the context is the table itself, which org recalculates.
2223 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
23 return TableRecalculate.tblfmLine(in: context) != nil || atStart ? TableRecalculate(all: true).run(in: context) : commitBuffer(context) { buffer in
24 if TableRecalculate.tblfmLine(in: context) != nil || atStart { return TableRecalculate(all: true).run(in: context) }
25 // `org-table-maybe-eval-formula`, then `org-table-maybe-recalculate-line` on a row
26 // marked `#`, which aligns, or else `org-table-align`.
27 let step = commitBuffer(context) { buffer in
2428 try buffer.tableMaybeEvalFormula()
25 buffer.tableAlign()
29 guard buffer.lookingAtLine("^[ \\t]*\\| *# *(\\||$)") else { return buffer.tableAlign() }
30 do {
31 try buffer.tableRecalculate(all: false)
32 } catch let error as Calc.Unsupported {
33 throw EmacsBuffer.UserError(message: TableRecalculate.emacsMessage + error.reason)
34 }
2635 }
36 return TableRecalculate.inEmacs(step, form: "(org-ctrl-c-ctrl-c)", in: context)
2737 }
2838 if buffer.footnoteReferenceAtPoint() != nil
2939 || buffer.footnoteDefinitions().contains(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) {
Sources/OrgCore/Commands/EmacsBuffer.swift +6 −3
@@ -38,6 +38,8 @@ final class EmacsBuffer {
3838 var calendar = Calendar.current
3939 /// `org-last-inserted-timestamp`.
4040 var lastInsertedTimestamp: String?
41 /// Shown after the command commits, as `message`.
42 var message: String?
4143
4244 let options: EditingOptions
4345
@@ -466,7 +468,7 @@ final class EmacsBuffer {
466468}
467469
468470/// Runs a buffer-editing body against a context and turns the result into one minimal edit
469/// and the new caret. A user error becomes `.failed`.
471/// and the new caret, with the buffer's message. A user error becomes `.failed`.
470472func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
471473 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
472474 buffer.now = context.now
@@ -499,9 +501,10 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void)
499501 while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 }
500502 while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 }
501503 let caret = buffer.point
504 let effects: [Effect] = buffer.message.map { [.message($0)] } ?? []
502505 guard old.count != new.count || prefix != old.count else {
503 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret]))
506 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret], effects: effects))
504507 }
505508 let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self))
506 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret]))
509 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret], effects: effects))
507510}
Sources/OrgCore/Commands/SubtreeCommands.swift +1 −1
@@ -111,7 +111,7 @@ public struct PasteSubtree: OrgCommand {
111111/// them.
112112public struct CloneSubtree: OrgCommand {
113113 let newID: @Sendable () -> String
114 public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) { self.newID = newID }
114 public init(newID: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }) { self.newID = newID }
115115 public var id: String { "org.subtree.clone" }
116116 public var title: String { "Clone Subtree with Time Shift" }
117117 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
Sources/OrgCore/Commands/Table.swift +3 −2
@@ -1,8 +1,9 @@
11import Foundation
22
33// Org tables (org-table.el, Org 9.8.7): alignment and the editing commands, with
4// `org-table-automatic-realign` t, `org-table-tab-jumps-over-hlines` t, no shrunk columns,
5// `org-table-fix-formulas-confirm` nil (formulas are fixed silently).
4// `org-table-automatic-realign` t, `org-table-tab-jumps-over-hlines` t,
5// `org-table-fix-formulas-confirm` nil (formulas are fixed silently). Shrunk columns are only
6// drawn (`TableShrink`); these commands see the full text.
67
78extension EmacsBuffer {
89 static let numberPattern = "^([<>]?[-+^.0-9]*[0-9][-+^.0-9eEdDx()%:]*|[<>]?[-+]?0[xX][0-9a-fA-F.]+|[<>]?[-+]?[0-9]+#[0-9a-zA-Z.]+|nan|[-+u]?inf)$"
Sources/OrgCore/Compute/TableFormulas.swift +31 −20
@@ -2,8 +2,9 @@ import Foundation
22
33// Table formulas (org-table.el, Org 9.8.7): `org-table-recalculate` and
44// `org-table-eval-formula` with Calc and Lisp evaluated natively. Symbolic results, Calc
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.
5// features beyond `Calc` and Lisp beyond `Elisp` throw `Calc.Unsupported`; the caller then
6// recalculates the table in Emacs. `remote()` finds tables by name in the file, and by ID
7// here or, through `RemoteTables`, in other files.
78
89/// Where `remote()` finds tables by ID in other files.
910public enum RemoteTables {
@@ -189,11 +190,12 @@ extension EmacsBuffer {
189190 return i == l - 1 ? i - 1 : i
190191 }
191192
192 /// `org-table--descriptor-line`.
193 /// `org-table--descriptor-line`, with `org-table-get-range`'s fallback to the current
194 /// line for `@0`, whose `org-table-dlines` entry is nil.
193195 static func descriptorLine(_ desc: String, _ cline: Int, analysis: TableAnalysis) throws -> Int {
194196 if let n = Int(desc), desc.allSatisfy(\.isNumber) {
195197 guard n < analysis.dlines.count else { throw Calc.Unsupported("row out of range") }
196 return analysis.dlines[n]
198 return n == 0 ? cline : analysis.dlines[n]
197199 }
198200 let ns = desc as NSString
199201 let regex = try! NSRegularExpression(pattern: "^(([-+])?(I+))?(([-+])?([0-9]+))?")
@@ -949,19 +951,24 @@ public struct TableRecalculate: OrgCommand {
949951 throw EmacsBuffer.UserError(message: Self.emacsMessage + error.reason)
950952 }
951953 }
952 guard case .failed(let message) = step, message.hasPrefix(Self.emacsMessage) else { return step }
953 // Emacs recalculates the table; its text replaces the table here.
954 let form = Self.tblfmLine(in: context) != nil ? "(org-table-calc-current-TBLFM)" : all ? "(org-table-recalculate t)" : "(org-table-recalculate nil)"
955 return Self.inEmacs(step, form: form, in: context)
956 }
957
958 /// A step that failed with `emacsMessage` as a request to run `form` on the table in
959 /// Emacs, whose text then replaces the table here; other steps unchanged.
960 static func inEmacs(_ step: CommandStep, form: String, in context: EditContext) -> CommandStep {
961 guard case .failed(let message) = step, message.hasPrefix(emacsMessage) else { return step }
954962 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
955 let onTblfm = Self.tblfmLine(in: context) != nil
963 let onTblfm = tblfmLine(in: context) != nil
956964 if onTblfm {
957965 while buffer.forwardLine(-1) == 0, !buffer.lookingAtLine("^[ \\t]*\\|") {}
958966 }
959967 let table = buffer.tableBegin()..<buffer.tableEnd()
960 let tblfm = onTblfm ? Self.tblfmLine(in: context) ?? "" : buffer.firstTblfm(after: table.upperBound) ?? ""
961 let form = onTblfm ? "(org-table-calc-current-TBLFM)" : all ? "(org-table-recalculate t)" : "(org-table-recalculate nil)"
968 let tblfm = onTblfm ? tblfmLine(in: context) ?? "" : buffer.firstTblfm(after: table.upperBound) ?? ""
962969 return .external(.emacsTable(
963970 table: table, point: context.caret, form: form,
964 reason: String(message.dropFirst(Self.emacsMessage.count)), lisp: tblfm.contains("'(")
971 reason: String(message.dropFirst(emacsMessage.count)), lisp: tblfm.contains("'(")
965972 ))
966973 }
967974}
@@ -1011,18 +1018,21 @@ extension EmacsBuffer {
10111018 }
10121019 }
10131020
1014 /// `org-table-get-formula` with EQUATION given: stores it for the current column, or with
1015 /// `named` the current field, and returns it.
1016 func tableGetFormula(_ equation: String, named: Bool, analysis: TableAnalysis) throws -> String {
1021 /// `org-table-get-formula`: stores EQUATION for the current column, or with `named` the
1022 /// current field, and returns it; nil when a blank EQUATION removed the formula. `read`
1023 /// when EQUATION is the prompt's answer, which the stored formula never replaces.
1024 func tableGetFormula(_ equation: String, named: Bool, read: Bool = false, analysis: TableAnalysis) throws -> String? {
10171025 var stored = try Self.storedFormulas(firstTblfm(after: tableEnd()) ?? "")
10181026 let ref = "@\(tableCurrentDataLine())$\(tableCurrentColumn())"
10191027 let scol = named ? ref : "$\(tableCurrentColumn())"
10201028 let old = stored.first { $0.lhs == scol }?.rhs
1021 var eq = old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation
1029 var eq = !read && old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation
10221030 guard eq.contains(where: { !$0.isWhitespace }) else {
10231031 stored.removeAll { $0.lhs == scol }
10241032 tableStoreFormulas(stored, analysis: analysis)
1025 throw UserError(message: "Formula removed")
1033 // Org's user error, which keeps the stored formulas and stops the command.
1034 message = "Formula removed"
1035 return nil
10261036 }
10271037 eq = eq.replacingOccurrences(of: "^ *=?", with: "", options: .regularExpression)
10281038 eq = eq.replacingOccurrences(of: " *$", with: "", options: .regularExpression)
@@ -1041,14 +1051,15 @@ extension EmacsBuffer {
10411051 }
10421052
10431053 /// `org-table-eval-formula` for the current field with EQUATION, which is stored first.
1044 func tableEvalFormula(equation: String, named: Bool) throws {
1054 /// A formula only Emacs can evaluate stays stored, the field unchanged.
1055 func tableEvalFormula(equation: String, named: Bool, read: Bool = false) throws {
10451056 try checkInsideDataField()
10461057 let analysis = try tableAnalyze()
1047 let formula = try tableGetFormula(equation, named: named, analysis: analysis)
1058 guard let formula = try tableGetFormula(equation, named: named, read: read, analysis: analysis) else { return }
10481059 do {
10491060 try tableEvalFormula(formula, analysis: analysis, substituted: false)
10501061 } catch let error as Calc.Unsupported {
1051 throw UserError(message: "The formula was stored; recalculating it needs Emacs: " + error.reason)
1062 message = "The formula was stored; recalculating it needs Emacs: " + error.reason
10521063 }
10531064 tableAlign()
10541065 }
@@ -1084,7 +1095,7 @@ public struct TableEvalFormula: OrgCommand {
10841095 let stored = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: buffer.tableEnd()) ?? ""))?.first { $0.lhs == scol }?.rhs
10851096 return .prompt(Prompt(key: "formula", message: "\(named ? "Field" : "Column") formula \(scol)=", initial: stored ?? ""))
10861097 }
1087 return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named) }
1098 return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named, read: true) }
10881099 }
10891100}
10901101
@@ -1162,7 +1173,7 @@ extension BlockEdit {
11621173 }
11631174 guard case .commit(let result) = step else { return step }
11641175 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection,
1165 effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")]))
1176 effects: result.effects + [.message("New formulas installed - Recalculate Table (C-c C-c on the #+TBLFM line) applies them.")]))
11661177 }
11671178}
11681179
Sources/OrgCore/Keymap/Presets.swift +1 −1
@@ -998,7 +998,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
998998 command = "org.fill-paragraph"
999999 [[bind]]
10001000 keys = "C-c C-c"
1001 command = "org.table.align"
1001 command = "org.ctrl-c-ctrl-c"
10021002 when = "table"
10031003 [[bind]]
10041004 keys = "C-c C-c"
Sources/OrgCore/Links/LinkStore.swift +1 −1
@@ -282,7 +282,7 @@ public struct InsertLink: OrgCommand {
282282public struct CreateID: OrgCommand {
283283 let newID: @Sendable () -> String
284284
285 public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) {
285 public init(newID: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }) {
286286 self.newID = newID
287287 }
288288
Sources/OrgCore/Links/Links.swift +68 −6
@@ -9,19 +9,22 @@ public struct OrgLink: Sendable, Equatable {
99 public let searchOption: String?
1010 /// The link as written, unescaped and with abbreviations expanded.
1111 public let raw: String
12 /// Where an `attachment` link starts in its file, whose entry holds the attachments.
13 public var position: Int?
1214
13 public init(type: String, path: String, searchOption: String? = nil, raw: String) {
15 public init(type: String, path: String, searchOption: String? = nil, raw: String, position: Int? = nil) {
1416 self.type = type
1517 self.path = path
1618 self.searchOption = searchOption
1719 self.raw = raw
20 self.position = position
1821 }
1922}
2023
2124public enum Links {
22 /// `org-link-parameters` in Emacs with Org's default modules.
25 /// `org-link-parameters` in Emacs with Org's default modules, and org-attach's.
2326 static let types = [
24 "id", "eww", "rmail", "mhe", "irc", "info", "gnus", "docview", "bibtex", "bbdb", "w3m", "doi", "file+sys", "file+emacs",
27 "attachment", "id", "eww", "rmail", "mhe", "irc", "info", "gnus", "docview", "bibtex", "bbdb", "w3m", "doi", "file+sys", "file+emacs",
2528 "shell", "news", "mailto", "https", "http", "ftp", "shortdoc", "help", "file", "elisp",
2629 ]
2730
@@ -132,11 +135,70 @@ public enum Links {
132135 walk(tree.root)
133136 guard let node = found else { return nil }
134137 let whole = node.text
138 var link: OrgLink
135139 if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) {
136 return (parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text)), node.range)
140 link = parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text))
141 } else if whole.hasPrefix("<") && whole.hasSuffix(">") {
142 link = parse(plain: String(whole.dropFirst().dropLast()))
143 } else if tree.settings.radioTargets.contains(where: { words($0) == words(whole) }) {
144 link = OrgLink(type: "radio", path: whole, raw: whole)
145 } else {
146 link = parse(plain: whole)
147 }
148 if link.type == "attachment" { link.position = node.range.lowerBound }
149 return (link, node.range)
150 }
151
152 /// Words without case, as radio links match their targets.
153 static func words(_ s: String) -> [String] { s.lowercased().split(whereSeparator: \.isWhitespace).map(String.init) }
154
155 /// `org-link--search-radio-target`: the `<<<target>>>` a radio link's text names.
156 public static func searchRadio(_ target: String, tree: OrgTree) -> SearchResult {
157 var found: Int?
158 func walk(_ node: SyntaxNode) {
159 for child in node.children where found == nil {
160 if child.kind == .radioTarget, words(String(child.text.dropFirst(3).dropLast(3))) == words(target) {
161 found = child.range.lowerBound
162 }
163 walk(child)
164 }
165 }
166 walk(tree.root)
167 return found.map { .found($0) } ?? .failed("No match for radio target: \(target)")
168 }
169
170 /// The file `org-attach-follow` opens for an `attachment` link, with org-attach's
171 /// defaults: in the entry's DIR property (or the older ATTACH_DIR), else in the folder for
172 /// its ID under `data/` that exists (`org-attach-id-to-path-function-list`), both relative
173 /// to the file's folder. Without an existing folder the file is relative to the file's
174 /// folder, as `org-attach-expand` has it. Nil without a folder to start from.
175 public static func attachmentFile(_ link: OrgLink, tree: OrgTree, path: String?, isDirectory: (String) -> Bool) -> OrgLink? {
176 let model = DocumentModel(tree: tree)
177 let offset = link.position ?? 0
178 let heading = model.headings.lastIndex { $0.sectionRange.contains(offset) }
179 let folder = path.map { ($0 as NSString).deletingLastPathComponent }
180 func absolute(_ p: String, in folder: String?) -> String? {
181 let expanded = (p as NSString).expandingTildeInPath
182 if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath }
183 return folder.map { (($0 as NSString).appendingPathComponent(expanded) as NSString).standardizingPath }
184 }
185 var candidates: [String] = []
186 if let dir = model.property("DIR", of: heading) ?? model.property("ATTACH_DIR", of: heading) {
187 candidates = [dir.value]
188 } else if let id = model.property("ID", of: heading)?.value, let first = id.first {
189 let chars = Array(id)
190 if chars.count > 2 { candidates.append("data/" + String(chars[..<2]) + "/" + String(chars[2...])) }
191 if chars.count > 6 { candidates.append("data/" + String(chars[..<6]) + "/" + String(chars[6...])) }
192 candidates.append("data/__/\(first)/\(id)")
193 }
194 let dir = candidates.lazy.compactMap { absolute($0, in: folder) }.first(where: isDirectory) ?? folder
195 var file = link.path
196 var search: String?
197 if let range = file.range(of: "::") {
198 search = String(file[range.upperBound...])
199 file = String(file[..<range.lowerBound])
137200 }
138 let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole
139 return (parse(plain: plain), node.range)
201 return absolute(file, in: dir).map { OrgLink(type: "file", path: $0, searchOption: search, raw: link.raw) }
140202 }
141203
142204 // MARK: - Searching a file
Sources/OrgCore/Semantic/DocumentModel.swift +3 −1
@@ -11,6 +11,8 @@ public struct DocumentModel: Sendable {
1111 public private(set) var fileProperties: [Property] = []
1212 public private(set) var tables: [TableModel] = []
1313 public private(set) var srcBlocks: [SrcBlockInfo] = []
14 /// Link targets before the first heading, as `HeadingInfo.links` has a heading's.
15 public private(set) var links: [String] = []
1416
1517 /// Always inherited, whatever the inheritance setting.
1618 static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"]
@@ -130,7 +132,7 @@ public struct DocumentModel: Sendable {
130132 case .timestamp:
131133 if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) }
132134 case .link:
133 if let heading { headings[heading].links.append(linkTarget(child)) }
135 if let heading { headings[heading].links.append(linkTarget(child)) } else { links.append(linkTarget(child)) }
134136 case .keyword:
135137 fileKeyword(child)
136138 case .table:
Sources/OrgIndex/FileRecord.swift +14 −8
@@ -108,6 +108,8 @@ public struct FileRecord: Sendable, Equatable {
108108 public let hash: String
109109 public let settingsVersion: Int
110110 public let headings: [HeadingRecord]
111 /// Links before the first heading, the file's own.
112 public let links: [LinkRow]
111113
112114 public static func hash(_ bytes: [UInt8]) -> String {
113115 SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined()
@@ -124,7 +126,7 @@ public struct FileRecord: Sendable, Equatable {
124126 var resolved = settings
125127 resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile)
126128 self.settingsVersion = Self.settingsVersion(settings, text: source.text, setupKeywords: resolved.org.setupKeywords)
127 headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved)
129 (headings, links) = kind == .conflict ? ([], []) : Self.rows(source.text, settings: resolved)
128130 }
129131
130132 /// The settings version a file's rows are built under: the index's, with the keywords of
@@ -157,11 +159,12 @@ public struct FileRecord: Sendable, Equatable {
157159 self.hash = Self.unreadHash(size: size, mtime: mtime)
158160 self.settingsVersion = settings.version
159161 headings = []
162 links = []
160163 }
161164
162165 public static func unreadHash(size: Int, mtime: Double) -> String { "unread:\(size):\(mtime)" }
163166
164 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] {
167 static func rows(_ text: String, settings: IndexSettings) -> (headings: [HeadingRecord], links: [LinkRow]) {
165168 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
166169 let utf16 = text.utf16
167170 func slice(_ range: Range<Int>) -> String {
@@ -174,7 +177,7 @@ public struct FileRecord: Sendable, Equatable {
174177 if let parent = heading.parent, firstChild[parent] == nil { firstChild[parent] = index }
175178 }
176179
177 return model.headings.enumerated().map { index, heading in
180 let headings = model.headings.enumerated().map { index, heading in
178181 let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound
179182 let tags = model.tags(of: index)
180183 return HeadingRecord(
@@ -197,13 +200,16 @@ public struct FileRecord: Sendable, Equatable {
197200 clocks: heading.clocks.map {
198201 ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes)
199202 },
200 links: heading.links.map { target in
201 let scheme = target.prefix { $0 != ":" }
202 let isScheme = scheme.count < target.count && !scheme.isEmpty && scheme.allSatisfy { $0.isLetter || $0 == "-" }
203 return LinkRow(type: isScheme ? scheme.lowercased() : "fuzzy", target: target)
204 }
203 links: heading.links.map(linkRow)
205204 )
206205 }
206 return (headings, model.links.map(linkRow))
207 }
208
209 static func linkRow(_ target: String) -> LinkRow {
210 let scheme = target.prefix { $0 != ":" }
211 let isScheme = scheme.count < target.count && !scheme.isEmpty && scheme.allSatisfy { $0.isLetter || $0 == "-" }
212 return LinkRow(type: isScheme ? scheme.lowercased() : "fuzzy", target: target)
207213 }
208214
209215 /// Own properties, plus inherited ones for keys that inherit.
Sources/OrgIndex/IndexStore.swift +36 −4
@@ -21,6 +21,7 @@ public struct IndexedHeading: Sendable, Hashable {
2121
2222public struct HeadingLocation: Sendable, Hashable {
2323 public let path: String
24 /// `fileOrdinal` for the file itself, before its first heading.
2425 public let ordinal: Int
2526 public let title: String
2627 public let start: Int
@@ -33,6 +34,13 @@ public struct HeadingLocation: Sendable, Hashable {
3334 self.start = start
3435 self.contentHash = contentHash
3536 }
37
38 public static let fileOrdinal = -1
39
40 /// The file before its first heading, titled by its name.
41 public static func file(_ path: String, contentHash: String) -> HeadingLocation {
42 HeadingLocation(path: path, ordinal: fileOrdinal, title: (path as NSString).lastPathComponent, start: 0, contentHash: contentHash)
43 }
3644}
3745
3846/// One reconciliation's worth of changes, applied in a single transaction.
@@ -134,6 +142,18 @@ public final class IndexStore: Sendable {
134142 END;
135143 """)
136144 }
145 migrator.registerMigration("v2") { db in
146 // Links before a file's first heading; files are read again to fill it.
147 try db.execute(sql: """
148 CREATE TABLE file_links (
149 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
150 type TEXT NOT NULL,
151 target TEXT NOT NULL
152 );
153 CREATE INDEX file_links_file ON file_links(file_id);
154 UPDATE files SET settings_version = -1;
155 """)
156 }
137157 return migrator
138158 }
139159
@@ -174,6 +194,9 @@ public final class IndexStore: Sendable {
174194 record.settingsVersion, Date().timeIntervalSince1970]
175195 )
176196 let fileID = db.lastInsertedRowID
197 for link in record.links {
198 try db.execute(sql: "INSERT INTO file_links VALUES (?, ?, ?)", arguments: [fileID, link.type, link.target])
199 }
177200 for heading in record.headings {
178201 try db.execute(
179202 sql: """
@@ -316,20 +339,29 @@ public final class IndexStore: Sendable {
316339 return indexed.filter { overlay[$0.path] == nil } + live
317340 }
318341
319 /// Every link and the heading it's in, unsaved buffers' in place of their files'.
342 /// Every link and the heading it's in, or the file for links before its first heading,
343 /// unsaved buffers' in place of their files'.
320344 public func links(overlay: [String: FileRecord] = [:]) throws -> [(source: HeadingLocation, target: String)] {
321345 let indexed = try database.read { db in
322346 try Row.fetchAll(
323347 db,
324348 sql: """
349 SELECT f.path, f.hash, -1 AS ordinal, '' AS title, 0 AS start_offset, l.target
350 FROM file_links l JOIN files f ON f.id = l.file_id WHERE f.kind = 'org'
351 UNION ALL
325352 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, l.target
326353 FROM links l JOIN headings h ON h.id = l.heading_id JOIN files f ON f.id = h.file_id
327 WHERE f.kind = 'org' ORDER BY f.path, h.ordinal
354 WHERE f.kind = 'org' ORDER BY 1, 3
328355 """
329 ).map { (source: location($0), target: $0["target"] as String) }
356 ).map { row in
357 let location = location(row)
358 return (source: location.ordinal == HeadingLocation.fileOrdinal ? .file(location.path, contentHash: location.contentHash) : location,
359 target: row["target"] as String)
360 }
330361 }
331362 let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in
332 record.headings.flatMap { heading in heading.links.map { (source: location(record, heading), target: $0.target) } }
363 record.links.map { (source: HeadingLocation.file(record.path, contentHash: record.hash), target: $0.target) }
364 + record.headings.flatMap { heading in heading.links.map { (source: location(record, heading), target: $0.target) } }
333365 }
334366 return indexed.filter { overlay[$0.source.path] == nil } + live
335367 }
Tests/OrgAppTests/AppTests.swift +28 −3
@@ -107,14 +107,22 @@ struct WorkspaceModelTests {
107107 let notes = try Folder()
108108 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n")
109109 try notes.write("b.org", "* Plumbing\n[[id:s-1][the sink]] and [[file:a.org]]\n* Other\n[[file:a.org::#sink]] [[https://x.org]]\n")
110 // Links before the first heading come from the file itself.
111 try notes.write("c.org", "#+TITLE: C\nAbout [[file:a.org::*Sink]] and [[file:a.org]].\n* Later\n")
110112 let model = try WorkspaceModel(directory: nil)
111113 try await model.addRoot(notes.url)
112114 let a = try #require(model.files.first { $0.path.hasSuffix("/a.org") }).path
115 let c = try #require(model.files.first { $0.path.hasSuffix("/c.org") }).path
113116 let sink = Backlinks.Target(path: a, title: "Sink", id: "s-1", customID: "sink")
114 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other"])
115 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing"])
117 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other", "c.org"])
118 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing", "c.org"])
116119 #expect(model.backlinks(to: Backlinks.Target(path: a, title: "Taps")).map(\.title) == ["Sink"])
117 #expect(model.backlinks(to: sink, excluding: 0).map(\.title) == ["Plumbing", "Other"])
120 #expect(model.backlinks(to: sink, excluding: 0).map(\.title) == ["Plumbing", "Other", "c.org"])
121 let fromC = try #require(model.backlinks(to: sink).last)
122 #expect(fromC == .file(c, contentHash: fromC.contentHash))
123 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
124 session.show(fromC)
125 #expect(session.path == c && session.revealRequest?.offset == 0)
118126 }
119127
120128 @Test func createRenameAndTrashFiles() async throws {
@@ -1019,4 +1027,21 @@ struct LinkFollowTests {
10191027 #expect(follow("doi:10.1000/182") == .openExternally(URL(string: "https://doi.org/10.1000/182")!))
10201028 #expect(follow("shell:rm -rf x") == .message("Orgstar doesn't run shell links"))
10211029 }
1030
1031 @Test func followsAttachmentAndRadioLinks() throws {
1032 let notes = try Folder()
1033 let text = "* Task\n:PROPERTIES:\n:ID: ab12-cd\n:END:\nSee [[attachment:scan.pdf]] and <<<the plan>>>.\n* Later\nAs in The Plan.\n"
1034 let a = try notes.write("a.org", text)
1035 let scan = try notes.write("data/ab/12-cd/scan.pdf", "pdf")
1036 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
1037 session.open(a.path)
1038 let tree = try #require(session.document?.tree)
1039 func follow(_ needle: String) throws -> LinkFollow {
1040 let link = try #require(Links.link(at: (text as NSString).range(of: needle).location + 1, text: text, tree: tree)?.link)
1041 return session.follow(link, workspace: nil, isText: { $0.hasSuffix(".org") })
1042 }
1043 #expect(try follow("attachment:") == .openExternally(URL(fileURLWithPath: (scan.path as NSString).standardizingPath)))
1044 #expect(try follow("The Plan") == .shown)
1045 #expect(session.revealRequest?.offset == (text as NSString).range(of: "<<<the plan>>>").location)
1046 }
10221047}
Tests/OrgCoreTests/KeymapTests.swift +1 −1
@@ -212,6 +212,6 @@ struct CtrlCCtrlCKeyTests {
212212 }
213213 #expect(command(at: 1) == "org.ctrl-c-ctrl-c")
214214 #expect(command(at: 5) == "org.ctrl-c-ctrl-c")
215 #expect(command(at: 13) == "org.table.align")
215 #expect(command(at: 13) == "org.ctrl-c-ctrl-c")
216216 }
217217}
Tests/OrgCoreTests/LinkStoreTests.swift +10
@@ -209,4 +209,14 @@ struct LinkStoreTests {
209209 }
210210 #expect(ours == emacs)
211211 }
212
213 @Test(.enabled(if: oracle))
214 func createdIDsAreLowercaseUUIDsLikeOrgID() throws {
215 let uuid = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
216 let emacs = try EmacsOracle.evaluate("* A\n", "(list (progn (require 'org-id) (org-id-uuid)))")
217 #expect(emacs.first?.range(of: uuid, options: .regularExpression) != nil, "\(emacs)")
218 let text = runCommand(CreateID(), "* A\n", caret: 0).text
219 let id = try #require(text.firstMatch(of: /:ID: +(\S+)/)?.1)
220 #expect(String(id).range(of: uuid, options: .regularExpression) != nil, "\(text)")
221 }
212222}
Tests/OrgCoreTests/LinkTests.swift +76
@@ -88,4 +88,80 @@ struct LinkTests {
8888 #expect(ours == expected, "\(s)")
8989 }
9090 }
91
92 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
93 func followsRadioLinksLikeOrg() throws {
94 let text = "* A\nSome <<<Radio Target>>> here.\n* B\nThen radio\ntarget again, and RADIO TARGET.\n"
95 let tree = OrgParser.parse(text)
96 let ns = text as NSString
97 let offsets = [ns.range(of: "radio\ntarget").location + 1, ns.range(of: "RADIO TARGET").location + 2]
98 let emacs = try EmacsOracle.run(offsets.map { EmacsOracle.Case(text: text, point: EmacsOracle.point($0, in: text), form: "(org-open-at-point)") })
99 for (offset, theirs) in zip(offsets, emacs) {
100 let link = try #require(Links.link(at: offset, text: text, tree: tree)?.link)
101 #expect(link.type == "radio")
102 #expect(Links.searchRadio(link.path, tree: tree) == .found(EmacsOracle.offset(theirs.point, in: theirs.text)), "\(theirs)")
103 }
104 #expect(Links.searchRadio("elsewhere", tree: tree) == .failed("No match for radio target: elsewhere"))
105 }
106
107 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
108 func findsAttachmentsLikeOrgAttach() throws {
109 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-attach-\(UUID().uuidString)")
110 defer { try? FileManager.default.removeItem(at: folder) }
111 for dir in ["top", "files", "data/3f/2a77c0-1111", "data/202601/05T101010"] {
112 try FileManager.default.createDirectory(at: folder.appendingPathComponent(dir), withIntermediateDirectories: true)
113 }
114 let text = """
115 :PROPERTIES:
116 :DIR: top
117 :END:
118 [[attachment:t.txt]]
119 * Dir
120 :PROPERTIES:
121 :DIR: files
122 :END:
123 [[attachment:a.txt::3]]
124 * By ID
125 :PROPERTIES:
126 :ID: 3f2a77c0-1111
127 :END:
128 [[attachment:b.txt]]
129 ** Child without its own
130 [[attachment:inherit.txt]]
131 * Missing
132 :PROPERTIES:
133 :ID: zz99-none
134 :END:
135 [[attachment:c.txt]]
136 * Timestamp ID
137 :PROPERTIES:
138 :ID: 20260105T101010
139 :END:
140 [[attachment:sub/d.txt]]
141
142 """
143 let file = folder.appendingPathComponent("notes.org").path
144 try text.write(toFile: file, atomically: true, encoding: .utf8)
145 let ns = text as NSString
146 let offsets = ["t.txt", "a.txt", "b.txt", "inherit.txt", "c.txt", "d.txt"].map { ns.range(of: $0).location }
147 let points = offsets.map { String(EmacsOracle.point($0, in: text)) }.joined(separator: " ")
148 let output = try EmacsOracle.run(["-Q", "--batch", "--eval", """
149 (progn (require 'org) (require 'org-attach)
150 (with-current-buffer (find-file-noselect "\(file)")
151 (dolist (p '(\(points)))
152 (goto-char p)
153 (princ (format "%s\\n" (org-attach-expand (org-element-property :path (org-element-context))))))))
154 """])
155 let emacs = output.split(separator: "\n").map(String.init).filter { $0.hasPrefix("/") }
156 let tree = OrgParser.parse(text)
157 let ours = offsets.map { offset -> String in
158 guard let link = Links.link(at: offset, text: text, tree: tree)?.link,
159 let target = Links.attachmentFile(link, tree: tree, path: file, isDirectory: { path in
160 var directory: ObjCBool = false
161 return FileManager.default.fileExists(atPath: path, isDirectory: &directory) && directory.boolValue
162 }) else { return "none" }
163 return target.path + (target.searchOption.map { "::" + $0 } ?? "")
164 }
165 #expect(ours == emacs, "\(output)")
166 }
91167}
Tests/OrgCoreTests/TableFormulaEntryTests.swift +36
@@ -13,6 +13,9 @@ struct TableFormulaEntryTests {
1313 let replaced = "| a | b |\n| 1 | =$1*5 |\n#+TBLFM: $2=$1*2::@1$2=x\n"
1414 let reuse = "| a | b |\n| 2 | = |\n#+TBLFM: $2=$1*2\n\nText\n"
1515 let last = "| 1 | =$1+1 |\n"
16 let marked = "| | x | y |\n|---+---+---|\n| # | 2 | |\n| | 3 | |\n#+TBLFM: $3=$2*10\n"
17 let markedTyped = "| # | 2 | =$2+1 | |\n| # | 5 | | |\n#+TBLFM: $4=$2*2\n"
18 let prompt = { (arg: String) in "(cl-letf (((symbol-function 'read-string) (lambda (&rest _) \"\"))) (org-table-eval-formula \(arg)))" }
1619 let cases: [(String, String, any OrgCommand, [String: String], String)] = [
1720 (plain, "*2", TableCommand(.nextField), [:], "(org-table-next-field)"),
1821 (plain, "*2", TableCommand(.nextRow), [:], "(org-table-next-row)"),
@@ -25,6 +28,11 @@ struct TableFormulaEntryTests {
2528 (stored, "| 3", TableEvalFormula(named: false), ["formula": "$1*3"], "(org-table-eval-formula nil \"$1*3\")"),
2629 (stored, "| 3", TableEvalFormula(named: true), ["formula": "99"], "(org-table-eval-formula '(4) \"99\")"),
2730 (stored, "| 1 | 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
31 (marked, "| 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
32 (marked, "| 3", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
33 (markedTyped, "+1", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
34 (stored, "| 3 | 4", TableEvalFormula(named: false), ["formula": ""], prompt("nil")),
35 (stored, "| 1", TableEvalFormula(named: true), ["formula": ""], prompt("'(4)")),
2836 ]
2937 let emacs = try EmacsOracle.run(cases.map { c in
3038 let caret = NSMaxRange((c.0 as NSString).range(of: c.1))
@@ -67,3 +75,31 @@ extension TableFormulaEntryTests {
6775 }
6876 }
6977}
78
79extension TableFormulaEntryTests {
80 /// Runs a command that should commit: the new text, the caret and the message.
81 func committed(_ command: any OrgCommand, _ text: String, after needle: String, answers: [String: String] = [:]) -> (text: String, caret: Int, message: String?)? {
82 let caret = NSMaxRange((text as NSString).range(of: needle))
83 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers)
84 guard case .commit(let result) = command.run(in: context) else { return nil }
85 var new = text
86 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
87 let message = result.effects.compactMap { effect -> String? in if case .message(let m) = effect { m } else { nil } }.first
88 return (new, result.selection?.first?.lowerBound ?? caret, message)
89 }
90
91 @Test func formulaEditsAreKeptWithTheirMessage() throws {
92 let text = "| a | b |\n|---+---|\n| 1 | 2 |\n#+TBLFM: $2=$1*2\n"
93 let removed = try #require(committed(TableEvalFormula(named: false), text, after: "| 1 | 2", answers: ["formula": ""]))
94 #expect(removed.text == "| a | b |\n|---+---|\n| 1 | 2 |\n#+TBLFM: \n" && removed.message == "Formula removed", "\(removed)")
95 // Text in a field is a Calc symbol, which needs Emacs: the formula is stored and TAB
96 // still moves on.
97 let symbolic = "| a | b |\n| x | =$1*2 |\n| y | |\n"
98 let tab = try #require(committed(TableCommand(.nextField), symbolic, after: "*2"))
99 #expect(tab.text == "| a | b |\n| x | =$1*2 |\n| y | |\n#+TBLFM: $2=$1*2\n", "\(tab.text.debugDescription)")
100 #expect(tab.caret == (tab.text as NSString).range(of: "| y").location + 2)
101 #expect(tab.message?.hasPrefix("The formula was stored; recalculating it needs Emacs") == true)
102 let prompted = try #require(committed(TableEvalFormula(named: false), symbolic, after: "| x", answers: ["formula": "$1+1"]))
103 #expect(prompted.text.hasSuffix("#+TBLFM: $1=$1+1\n") && prompted.message != nil, "\(prompted)")
104 }
105}
Tests/OrgCoreTests/TableFormulaTests.swift +2
@@ -56,6 +56,8 @@ struct TableFormulaTests {
5656 "| a | b |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: @-1$2=5\n",
5757 "| a | b | f |\n|---+---+---|\n| 1 | 3 | |\n| 7 | 2 | |\n| -7 | 2 | |\n| 6 | 3 | |\n| 1 | 0.5 | |\n| 4 | 6 | |\n#+TBLFM: $3=$1/$2;F\n",
5858 "| a |\n|---|\n| |\n| |\n| |\n| |\n| |\n| |\n| |\n| |\n| |\n#+TBLFM: @2$1=1/3+1/6;F::@3$1=(1/3)^2;F::@4$1=vsum([1/2,1/3]);F::@5$1=1/3*3;F::@6$1=sqrt(1/4);F::@7$1=vmean([1,2]);F::@8$1=2^-2;F::@9$1=abs(-1/3)+max(1/3,1/4);F::@10$1=round(7/2)+floor(7/2);F\n",
59 "| 10 | 20 | |\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3=@0$1+@0$2*10\n",
60 "#+NAME: t1\n| 5 | 6 |\n| 1 | 2 |\n\n#+NAME: t2\n| 7 | 8 |\n\n| name | val | sum |\n|------+-----+-----|\n| t1 | | |\n| t2 | | |\n#+TBLFM: $2=remote($1,@1$1)::$3=vsum(remote(t1,$1..$2))+vsum($1..$2)\n",
5961 ]
6062
6163 @Test func remoteFindsTablesByIDInOtherFiles() {
Tests/OrgIndexTests/FileRecordTests.swift +6
@@ -54,6 +54,12 @@ struct FileRecordTests {
5454 #expect(child.properties.contains(PropertyRow(key: "OWNER", value: "team", inherited: true)))
5555 }
5656
57 @Test func linksBeforeTheFirstHeadingBelongToTheFile() {
58 let file = record("/notes/a.org", "#+TITLE: A\nSee [[file:b.org]] and https://x.org.\n* H\n[[id:h]]\n")
59 #expect(file.links == [LinkRow(type: "file", target: "file:b.org"), LinkRow(type: "https", target: "https://x.org")])
60 #expect(file.headings[0].links == [LinkRow(type: "id", target: "id:h")])
61 }
62
5763 @Test func archiveTag() {
5864 let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings
5965 #expect(headings.map(\.archived) == [true, true, false])