Follow links !65

merged merged by cmc on 2026-10-06 00:49 UTC · krz/orgstar:links-open into main

13 files changed, +512 −0

Layout: unified · split

Sources/OrgApp/LinkFollow.swift added +86
@@ -0,0 +1,86 @@
1import Foundation
2import OrgCore
3
4/// What following a link comes to.
5public enum LinkFollow: Equatable, Sendable {
6 /// Shown in Orgstar: a buffer opened or a heading revealed.
7 case shown
8 /// For the system to open: a web or mail address, or a file Orgstar doesn't edit.
9 case openExternally(URL)
10 case message(String)
11}
12
13extension DocumentSession {
14 /// 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.
17 public func follow(_ link: OrgLink, workspace: WorkspaceModel?, isText: (String) -> Bool) -> LinkFollow {
18 switch link.type {
19 case "http", "https", "ftp", "mailto", "news":
20 return Self.url(link.raw)
21 case "doi":
22 return Self.url("https://doi.org/" + link.path)
23 case "file":
24 return followFile(link, isText: isText)
25 case "id":
26 guard let location = try? workspace?.index.headings(withID: link.path).first else {
27 return .message("No heading has the ID \(link.path)")
28 }
29 show(location)
30 return .shown
31 case "custom-id":
32 return searchCurrent("#" + link.path)
33 case "fuzzy":
34 return searchCurrent(link.path)
35 case "coderef":
36 return .message("Coderef links aren't supported")
37 case "shell", "elisp":
38 return .message("Orgstar doesn't run \(link.type) links")
39 default:
40 return .message("Orgstar can't open \(link.type) links")
41 }
42 }
43
44 static func url(_ string: String) -> LinkFollow {
45 let allowed = CharacterSet.urlFragmentAllowed.union(.urlQueryAllowed).union(CharacterSet(charactersIn: "#%"))
46 guard let url = URL(string: string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string) else {
47 return .message("Not a link Orgstar can open: \(string)")
48 }
49 return .openExternally(url)
50 }
51
52 /// A file link's path against the open file's folder, as Org resolves relative links.
53 public static func resolve(_ path: String, from current: String?) -> String {
54 let expanded = (path as NSString).expandingTildeInPath
55 if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath }
56 let folder = current.map { ($0 as NSString).deletingLastPathComponent } ?? FileManager.default.homeDirectoryForCurrentUser.path
57 return ((folder as NSString).appendingPathComponent(expanded) as NSString).standardizingPath
58 }
59
60 private func followFile(_ link: OrgLink, isText: (String) -> Bool) -> LinkFollow {
61 let target = link.path.isEmpty ? (path ?? "") : Self.resolve(link.path, from: path)
62 guard FileManager.default.fileExists(atPath: target) else { return .message("No file \(target)") }
63 guard Self.isOrgPath(target) || isText(target) else { return .openExternally(URL(fileURLWithPath: target)) }
64 open(target)
65 guard let option = link.searchOption, !option.isEmpty else { return .shown }
66 if let line = Int(option), let text = document?.text {
67 let ns = text as NSString
68 var offset = 0
69 for _ in 1..<max(1, line) where offset < ns.length { offset = NSMaxRange(ns.lineRange(for: NSRange(location: offset, length: 0))) }
70 reveal(offset)
71 return .shown
72 }
73 return searchCurrent(option)
74 }
75
76 private func searchCurrent(_ s: String) -> LinkFollow {
77 guard let document else { return .message("No file is open") }
78 switch Links.search(s, text: document.text, tree: document.tree) {
79 case .found(let offset):
80 reveal(offset)
81 return .shown
82 case .failed(let message):
83 return .message(message)
84 }
85 }
86}
Sources/OrgCore/Commands/Command.swift +3
@@ -148,6 +148,8 @@ public enum ExternalRequest: Sendable, Equatable {
148 case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool) 148 case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool)
149 /// Run a src block; `Babel.finish` turns its output into the result edit. 149 /// Run a src block; `Babel.finish` turns its output into the result edit.
150 case babel(BabelJob) 150 case babel(BabelJob)
151 /// Follow a link: the app opens URLs and files and searches headings.
152 case openLink(OrgLink)
151} 153}
152 154
153/// A named operation. Keys, menus, the palette and touch controls all run commands. 155/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -175,6 +177,7 @@ public enum Commands {
175 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), 177 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
176 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), 178 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
177 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 179 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
180 OpenLinkAtPoint(),
178 ] 181 ]
179 182
180 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } 183 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/Dwim.swift +2
@@ -12,6 +12,8 @@ public struct OrgDwim: OrgCommand {
12 public func applies(in context: EditContext) -> Bool { true } 12 public func applies(in context: EditContext) -> Bool { true }
13 13
14 public func run(in context: EditContext) -> CommandStep { 14 public func run(in context: EditContext) -> CommandStep {
15 // `+org/dwim-at-point` follows a link first.
16 if Links.link(at: context.caret, text: context.text, tree: context.tree) != nil { return OpenLinkAtPoint().run(in: context) }
15 if TableRecalculate.tblfmLine(in: context) != nil { return TableRecalculate(all: true).run(in: context) } 17 if TableRecalculate.tblfmLine(in: context) != nil { return TableRecalculate(all: true).run(in: context) }
16 if KeyContext.holds("src", in: context) { return ExecuteSrcBlock().run(in: context) } 18 if KeyContext.holds("src", in: context) { return ExecuteSrcBlock().run(in: context) }
17 if KeyContext.holds("table", in: context) { 19 if KeyContext.holds("table", in: context) {
Sources/OrgCore/Keymap/Presets.swift +3
@@ -551,6 +551,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
551 keys = "C-x C-s" 551 keys = "C-x C-s"
552 command = "app.save" 552 command = "app.save"
553 [[bind]] 553 [[bind]]
554 keys = "C-c C-o"
555 command = "org.link.open"
556 [[bind]]
554 keys = "C-x b" 557 keys = "C-x b"
555 command = "app.buffer.switch" 558 command = "app.buffer.switch"
556 [[bind]] 559 [[bind]]
Sources/OrgCore/Links/Links.swift added +213
@@ -0,0 +1,213 @@
1import Foundation
2
3/// A link as `org-element-link-parser` reads it.
4public struct OrgLink: Sendable, Equatable {
5 /// `https`, `file`, `id`, `custom-id`, `coderef`, `fuzzy`, or another registered type.
6 public let type: String
7 public let path: String
8 /// What follows `::` in a file link.
9 public let searchOption: String?
10 /// The link as written, unescaped and with abbreviations expanded.
11 public let raw: String
12
13 public init(type: String, path: String, searchOption: String? = nil, raw: String) {
14 self.type = type
15 self.path = path
16 self.searchOption = searchOption
17 self.raw = raw
18 }
19}
20
21public enum Links {
22 /// `org-link-parameters` in Emacs with Org's default modules.
23 static let types = [
24 "id", "eww", "rmail", "mhe", "irc", "info", "gnus", "docview", "bibtex", "bbdb", "w3m", "doi", "file+sys", "file+emacs",
25 "shell", "news", "mailto", "https", "http", "ftp", "shortdoc", "help", "file", "elisp",
26 ]
27
28 /// `org-link-types-re`: a registered type and its colon, longest names first.
29 static let typesPattern = try! NSRegularExpression(
30 pattern: "^(" + types.sorted { $0.count > $1.count }.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|") + "):"
31 )
32
33 /// `org-link-unescape`: halves each run of backslashes before a bracket or the end.
34 static func unescape(_ s: String) -> String {
35 let regex = try! NSRegularExpression(pattern: "(\\\\+)(?=$|[\\[\\]])")
36 let ns = s as NSString
37 var out = s
38 for m in regex.matches(in: s, range: NSRange(location: 0, length: ns.length)).reversed() {
39 let run = m.range(at: 1)
40 out = (out as NSString).replacingCharacters(in: run, with: String(repeating: "\\", count: run.length / 2))
41 }
42 return out
43 }
44
45 /// `#+LINK: key template` lines of a file.
46 public static func abbreviations(in text: String) -> [String: String] {
47 var result: [String: String] = [:]
48 let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+LINK:[ \\t]+(\\S+)[ \\t]+(.*?)[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
49 let ns = text as NSString
50 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) where result[ns.substring(with: m.range(at: 1))] == nil {
51 result[ns.substring(with: m.range(at: 1))] = ns.substring(with: m.range(at: 2))
52 }
53 return result
54 }
55
56 /// `org-link-expand-abbrev` for templates with `%s`, `%h`, or a plain prefix.
57 static func expandAbbreviation(_ link: String, _ abbreviations: [String: String]) -> String {
58 let regex = try! NSRegularExpression(pattern: "^([^:]*)(::?(.*))?$")
59 let ns = link as NSString
60 guard let m = regex.firstMatch(in: link, range: NSRange(location: 0, length: ns.length)),
61 let template = abbreviations[ns.substring(with: m.range(at: 1))] else { return link }
62 let tag = m.range(at: 2).location == NSNotFound ? "" : ns.substring(with: m.range(at: 3))
63 if template.contains("%s") {
64 return template.replacingOccurrences(of: "%s", with: tag, options: [], range: template.range(of: "%s"))
65 }
66 if template.contains("%h") {
67 let hex = tag.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+?#"))) ?? tag
68 return template.replacingOccurrences(of: "%h", with: hex, options: [], range: template.range(of: "%h"))
69 }
70 return template + tag
71 }
72
73 /// A bracket link's inside (`[[` to `]]` or `][`), as `org-element-link-parser` reads it.
74 public static func parse(bracket inside: String, abbreviations: [String: String] = [:]) -> OrgLink {
75 let joined = inside.replacingOccurrences(of: "[ \\t]*\\n[ \\t]*", with: " ", options: .regularExpression)
76 let raw = expandAbbreviation(unescape(joined), abbreviations)
77 let ns = raw as NSString
78 var type: String
79 var path: String
80 if raw.hasPrefix("/") || raw.hasPrefix("~") || raw.hasPrefix("./") || raw.hasPrefix("../") {
81 type = "file"
82 path = raw
83 } else if let m = typesPattern.firstMatch(in: raw, range: NSRange(location: 0, length: ns.length)) {
84 type = ns.substring(with: m.range(at: 1))
85 path = ns.substring(from: NSMaxRange(m.range))
86 } else if raw.hasPrefix("("), raw.hasSuffix(")"), raw.count >= 2 {
87 type = "coderef"
88 path = String(raw.dropFirst().dropLast())
89 } else if raw.hasPrefix("#") {
90 type = "custom-id"
91 path = String(raw.dropFirst())
92 } else {
93 type = "fuzzy"
94 path = raw
95 }
96 return fileParts(type: type, path: path, raw: raw)
97 }
98
99 /// A plain or angle link: `type:path` as written.
100 public static func parse(plain: String) -> OrgLink {
101 let ns = plain as NSString
102 guard let m = typesPattern.firstMatch(in: plain, range: NSRange(location: 0, length: ns.length)) else {
103 return OrgLink(type: "fuzzy", path: plain, raw: plain)
104 }
105 let path = ns.substring(from: NSMaxRange(m.range)).replacingOccurrences(of: "[ \\t]*\\n[ \\t]*", with: "", options: .regularExpression)
106 return fileParts(type: ns.substring(with: m.range(at: 1)), path: path, raw: plain)
107 }
108
109 /// `file` and `file+app` links keep their `::search` apart.
110 static func fileParts(type: String, path: String, raw: String) -> OrgLink {
111 guard type == "file" || type.hasPrefix("file+") else { return OrgLink(type: type, path: path, raw: raw) }
112 var path = path
113 var search: String?
114 if let range = path.range(of: "::(.*)$", options: .regularExpression) {
115 search = String(path[range].dropFirst(2))
116 path.removeSubrange(range)
117 }
118 path = path.replacingOccurrences(of: "^///*([A-Za-z]:)?/", with: "$1/", options: .regularExpression)
119 return OrgLink(type: "file", path: path, searchOption: search, raw: raw)
120 }
121
122 /// The link at `offset`: the link node holding it, read with the file's abbreviations.
123 public static func link(at offset: Int, text: String, tree: OrgTree) -> (link: OrgLink, range: Range<Int>)? {
124 var found: SyntaxNode?
125 func walk(_ node: SyntaxNode) {
126 for child in node.children where child.range.lowerBound <= offset && offset < child.range.upperBound
127 || (child.range.upperBound == offset && child.kind == .link) {
128 if child.kind == .link { found = child }
129 walk(child)
130 }
131 }
132 walk(tree.root)
133 guard let node = found else { return nil }
134 let whole = node.text
135 if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) {
136 return (parse(bracket: path.text, abbreviations: abbreviations(in: text)), node.range)
137 }
138 let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole
139 return (parse(plain: plain), node.range)
140 }
141
142 // MARK: - Searching a file
143
144 public enum SearchResult: Sendable, Equatable {
145 case found(Int)
146 case failed(String)
147 }
148
149 /// `org-link-search` in an org file: a `#custom-id`, a `<<target>>`, a `#+NAME:`, or a
150 /// heading with these words (`*` asks for a heading only).
151 public static func search(_ s: String, text: String, tree: OrgTree) -> SearchResult {
152 guard s.contains(where: { !$0.isWhitespace }) else { return .failed("Invalid search string \"\(s)\"") }
153 let ns = text as NSString
154 let all = NSRange(location: 0, length: ns.length)
155 let normalized = s.replacingOccurrences(of: "\\n[ \\t]*", with: " ", options: .regularExpression)
156 let starred = normalized.hasPrefix("*")
157 let words = (starred ? String(s.dropFirst()) : s).split(whereSeparator: \.isWhitespace).map(String.init)
158 let model = DocumentModel(tree: tree)
159
160 if normalized.hasPrefix("#") {
161 let id = String(normalized.dropFirst())
162 for heading in model.headings where heading.properties.contains(where: { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id }) {
163 return .found(heading.headingRange.lowerBound)
164 }
165 return .failed("No match for custom ID: \(id)")
166 }
167 if normalized.hasPrefix("("), normalized.hasSuffix(")") {
168 return .failed("Coderef links aren't supported")
169 }
170 if normalized.count >= 2, normalized.hasPrefix("/"), normalized.hasSuffix("/") {
171 return .failed("Regexp links aren't supported")
172 }
173 let quoted = words.map(NSRegularExpression.escapedPattern(for:))
174 if !starred {
175 // A dedicated target, not part of a radio target.
176 let target = try! NSRegularExpression(pattern: "(?<!<)<<" + quoted.joined(separator: "(?:[ \\t\\n]+)") + ">>(?!>)", options: .caseInsensitive)
177 if let m = target.firstMatch(in: text, range: all) { return .found(m.range.location) }
178 let name = try! NSRegularExpression(pattern: "^[ \\t]*#\\+NAME: +" + quoted.joined(separator: "[ \\t]+") + "[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
179 if let m = name.firstMatch(in: text, range: all) { return .found(ns.lineRange(for: m.range).location) }
180 }
181 let upper = words.map { $0.uppercased() }
182 for heading in model.headings {
183 let title = normalizedTitle(heading.title)
184 if title.split(whereSeparator: \.isWhitespace).map({ $0.uppercased() }) == upper {
185 return .found(heading.headingRange.lowerBound)
186 }
187 }
188 return .failed("No match for fuzzy expression: \(normalized)")
189 }
190
191 /// `org-link--normalize-string` of a heading title: statistics cookies and runs of space go.
192 static func normalizedTitle(_ title: String) -> String {
193 var t = title
194 if t == "COMMENT" { t = "" } else if t.hasPrefix("COMMENT ") { t = String(t.dropFirst(8)) }
195 t = t.replacingOccurrences(of: "\\[[0-9]*(%|/[0-9]*)\\]", with: " ", options: .regularExpression)
196 t = t.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
197 return t.trimmingCharacters(in: .whitespaces)
198 }
199}
200
201/// `org-open-at-point` on a link (C-c C-o).
202public struct OpenLinkAtPoint: OrgCommand {
203 public init() {}
204 public var id: String { "org.link.open" }
205 public var title: String { "Open Link" }
206 public func applies(in context: EditContext) -> Bool {
207 Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
208 }
209 public func run(in context: EditContext) -> CommandStep {
210 guard let found = Links.link(at: context.caret, text: context.text, tree: context.tree) else { return .failed("No link to follow here") }
211 return .external(.openLink(found.link))
212 }
213}
Sources/OrgEditorAppKit/Keys.swift +7
@@ -80,7 +80,14 @@ final class OrgTextView: NSTextView {
80 return super.performKeyEquivalent(with: event) 80 return super.performKeyEquivalent(with: event)
81 } 81 }
82 82
83 /// ⌘-click: given the clicked offset, true when it followed a link.
84 var onCommandClick: ((Int) -> Bool)?
85
83 override func mouseDown(with event: NSEvent) { 86 override func mouseDown(with event: NSEvent) {
87 if event.modifierFlags.contains(.command), let onCommandClick {
88 let offset = characterIndexForInsertion(at: convert(event.locationInWindow, from: nil))
89 if onCommandClick(offset) { return }
90 }
84 onMouseDown?() 91 onMouseDown?()
85 super.mouseDown(with: event) 92 super.mouseDown(with: event)
86 } 93 }
Sources/OrgEditorAppKit/OrgEditor.swift +6
@@ -112,6 +112,12 @@ public final class OrgEditor: NSObject {
112 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) 112 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
113 super.init() 113 super.init()
114 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false } 114 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
115 orgTextView.onCommandClick = { [weak self] offset in
116 guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil else { return false }
117 self.setCaret(offset)
118 self.run("org.link.open")
119 return true
120 }
115 orgTextView.onMouseDown = { [weak self] in 121 orgTextView.onMouseDown = { [weak self] in
116 self?.markActive = false 122 self?.markActive = false
117 self?.modalMouseDown() 123 self?.modalMouseDown()
Sources/Orgstar/ContentView.swift +2
@@ -342,6 +342,8 @@ struct ContentView: View {
342 /// Work a command hands to the platform: tables recalculated in Emacs. 342 /// Work a command hands to the platform: tables recalculated in Emacs.
343 private func runExternal(_ request: ExternalRequest) { 343 private func runExternal(_ request: ExternalRequest) {
344 switch request { 344 switch request {
345 case .openLink(let link):
346 if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) }
345 case .emacsTable(let table, let point, let form, let reason, let lisp): 347 case .emacsTable(let table, let point, let form, let reason, let lisp):
346 let run = { 348 let run = {
347 commands.show("Recalculating in Emacs (\(reason))…") 349 commands.show("Recalculating in Emacs (\(reason))…")
Sources/Orgstar/LinkOpener.swift added +18
@@ -0,0 +1,18 @@
1import AppKit
2import OrgApp
3import OrgCore
4
5/// Follows a link, handing addresses and files Orgstar doesn't edit to macOS.
6@MainActor
7enum LinkOpener {
8 /// Returns a message when the link can't be followed.
9 static func open(_ link: OrgLink, session: DocumentSession, workspace: WorkspaceModel) -> String? {
10 switch session.follow(link, workspace: workspace, isText: FileTypes.isText) {
11 case .shown: return nil
12 case .openExternally(let url):
13 NSWorkspace.shared.open(url)
14 return nil
15 case .message(let message): return message
16 }
17 }
18}
Tests/OrgAppTests/AppTests.swift +37
@@ -732,3 +732,40 @@ struct BufferTests {
732 #expect(session.openDocuments.first { $0.path == a.path }?.document.text == "* A changed\n") 732 #expect(session.openDocuments.first { $0.path == a.path }?.document.text == "* A changed\n")
733 } 733 }
734} 734}
735
736@MainActor
737struct LinkFollowTests {
738 @Test func followsLinksOfEachKind() async throws {
739 let notes = try Folder()
740 let a = try notes.write("a.org", "* Start\nSee <<here>>.\n* Second [1/2]\n:PROPERTIES:\n:CUSTOM_ID: two\n:END:\n")
741 let b = try notes.write("sub/b.org", "* Target heading\n:PROPERTIES:\n:ID: 1234-ABCD\n:END:\nline 5\nline 6\n")
742 _ = try notes.write("pic.png", "not really")
743 let workspace = try WorkspaceModel(directory: nil)
744 try await workspace.addRoot(notes.url)
745 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
746 session.open(a.path)
747 let text = { (path: String) in try? String(contentsOfFile: path, encoding: .utf8) }
748 func follow(_ raw: String) -> LinkFollow { session.follow(Links.parse(bracket: raw), workspace: workspace, isText: { $0.hasSuffix(".org") }) }
749
750 #expect(follow("here") == .shown)
751 #expect(session.revealRequest?.offset == (text(a.path)! as NSString).range(of: "<<here>>").location)
752 #expect(follow("#two") == .shown)
753 #expect(follow("Second") == .shown)
754 #expect(follow("Nowhere") == .message("No match for fuzzy expression: Nowhere"))
755
756 #expect(follow("file:sub/b.org::5") == .shown)
757 #expect(session.path == b.path)
758 #expect(session.revealRequest?.offset == (text(b.path)! as NSString).range(of: "line 5").location)
759 session.open(a.path)
760 #expect(follow("file:sub/b.org::*Target heading") == .shown && session.path == b.path)
761 session.open(a.path)
762 #expect(follow("id:1234-ABCD") == .shown && session.path == b.path)
763 #expect(follow("id:none") == .message("No heading has the ID none"))
764 // Relative to the open file (now sub/b.org); files Orgstar doesn't edit go to macOS.
765 #expect(follow("file:../pic.png") == .openExternally(URL(fileURLWithPath: (notes.url.appendingPathComponent("pic.png").path as NSString).standardizingPath)))
766 #expect(follow("file:pic.png") == .message("No file " + (notes.url.appendingPathComponent("sub/pic.png").path as NSString).standardizingPath))
767 #expect(follow("https://orgmode.org/a b") == .openExternally(URL(string: "https://orgmode.org/a%20b")!))
768 #expect(follow("doi:10.1000/182") == .openExternally(URL(string: "https://doi.org/10.1000/182")!))
769 #expect(follow("shell:rm -rf x") == .message("Orgstar doesn't run shell links"))
770 }
771}
Tests/OrgCoreTests/EmacsOracle.swift +29
@@ -100,6 +100,35 @@ enum EmacsOracle {
100 return String(decoding: data, as: UTF8.self) 100 return String(decoding: data, as: UTF8.self)
101 } 101 }
102 102
103 /// Evaluates `form`, which returns a list of strings, in an org-mode buffer visiting a
104 /// file that holds `text`; `prelude` runs first (settings). The strings come back in order.
105 static func evaluate(_ text: String, _ form: String, prelude: String = "", file: String = "oracle.org") throws -> [String] {
106 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-eval-\(UUID().uuidString)")
107 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
108 defer { try? FileManager.default.removeItem(at: folder) }
109 let url = folder.appendingPathComponent(file)
110 try text.write(to: url, atomically: true, encoding: .utf8)
111 let output = folder.appendingPathComponent("out.json")
112 let script = folder.appendingPathComponent("eval.el")
113 try """
114 ;;; -*- lexical-binding: t -*-
115 (require 'org)
116 (require 'json)
117 \(prelude)
118 (with-current-buffer (find-file-noselect "\(url.path)")
119 (org-mode)
120 (let ((result \(form)))
121 (let ((coding-system-for-write 'utf-8-unix))
122 (with-temp-file (getenv "ORACLE_OUTPUT")
123 (insert (json-encode (vconcat result)))))))
124 """.write(to: script, atomically: true, encoding: .utf8)
125 let log = try run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"])
126 guard let data = try? Data(contentsOf: output) else { throw EvaluateError(log: log) }
127 return try JSONDecoder().decode([String].self, from: data)
128 }
129
130 struct EvaluateError: Error { let log: String }
131
103 /// UTF-16 offset to Emacs point. 132 /// UTF-16 offset to Emacs point.
104 static func point(_ offset: Int, in text: String) -> Int { 133 static func point(_ offset: Int, in text: String) -> Int {
105 let index = String.Index(utf16Offset: offset, in: text) 134 let index = String.Index(utf16Offset: offset, in: text)
Tests/OrgCoreTests/LinkTests.swift added +91
@@ -0,0 +1,91 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct LinkTests {
6 static let links = [
7 "[[https://orgmode.org/manual][the manual]]", "[[file:notes.org::*Heading two]]", "[[./sub/a.org::#custom]]",
8 "[[~/x.org::42]]", "[[/abs/path.txt]]", "[[file+sys:/a.pdf]]", "[[id:3F2A-77]]", "[[#my-id][custom]]", "[[(ref)]]",
9 "[[Some heading]]", "[[*Starred title]]", "[[gh:krz/orgstar]]", "[[wiki:Org mode]]", "[[mailto:a@b.c]]",
10 "[[doi:10.1000/182]]", "[[a \\[b\\] c]]", "[[file:a.org::target text]]", "[[shell:ls -l]]", "[[unknown:thing]]",
11 "<https://example.com/a b>", "https://example.com/x?y=1", "file:plain.org::*H",
12 ]
13
14 static let header = "#+LINK: gh https://github.com/%s\n#+LINK: wiki https://en.wikipedia.org/wiki/\n"
15
16 static func emacsParse() throws -> [String] {
17 let text = header + links.map { "- " + $0 + "\n" }.joined()
18 return try EmacsOracle.evaluate(text, """
19 (let (out)
20 (goto-char (point-min))
21 (forward-line 2)
22 (while (not (eobp))
23 (forward-char 2)
24 (let ((l (org-element-link-parser)))
25 (push (format "%s|%s|%s|%s" (org-element-property :type l) (org-element-property :path l)
26 (or (org-element-property :search-option l) "-") (org-element-property :raw-link l)) out))
27 (forward-line 1))
28 (nreverse out))
29 """)
30 }
31
32 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
33 func parsesLikeOrgElement() throws {
34 let emacs = try Self.emacsParse()
35 let text = Self.header + Self.links.map { "- " + $0 + "\n" }.joined()
36 let tree = OrgParser.parse(text)
37 let ns = text as NSString
38 var ours: [String] = []
39 for link in Self.links {
40 let offset = ns.range(of: "- " + link).location + 3
41 guard let found = Links.link(at: offset, text: text, tree: tree) else { ours.append("none"); continue }
42 let l = found.link
43 ours.append("\(l.type)|\(l.path)|\(l.searchOption ?? "-")|\(l.raw)")
44 }
45 #expect(ours == emacs)
46 }
47
48 static let searchText = """
49 #+TITLE: Search
50 * First heading
51 Body with <<a target>> and <<<radio>>>.
52 * TODO [#A] Second [1/2] heading :tag:
53 :PROPERTIES:
54 :CUSTOM_ID: second
55 :END:
56 #+NAME: my table
57 | a |
58 * COMMENT Third
59 ** Nested heading
60
61 """
62
63 static let searches = [
64 "a target", "A Target", "my table", "Second heading", "*Second heading", "#second", "#missing", "Third", "nested heading",
65 "radio", "no such thing", "*a target", "(ref)", "/re/",
66 ]
67
68 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
69 func searchesLikeOrgLinkSearch() throws {
70 let quoted = Self.searches.map { "\"" + $0.replacingOccurrences(of: "\"", with: "\\\"") + "\"" }.joined(separator: " ")
71 let emacs = try EmacsOracle.evaluate(Self.searchText, """
72 (let ((org-link-search-must-match-exact-headline t) out)
73 (dolist (s (list \(quoted)))
74 (goto-char (point-min))
75 (push (condition-case err (progn (org-link-search s) (format "at %d" (point)))
76 (error (error-message-string err)))
77 out))
78 (nreverse out))
79 """)
80 let tree = OrgParser.parse(Self.searchText)
81 for (s, expected) in zip(Self.searches, emacs) {
82 let ours: String = switch Links.search(s, text: Self.searchText, tree: tree) {
83 case .found(let offset): "at \(EmacsOracle.point(offset, in: Self.searchText))"
84 case .failed(let message): message
85 }
86 // Org opens a sparse tree for regexps and finds coderefs only in blocks; Orgstar says so.
87 if s == "(ref)" || s == "/re/" { continue }
88 #expect(ours == expected, "\(s)")
89 }
90 }
91}
Tests/OrgEditorAppKitTests/FindTests.swift +15
@@ -41,3 +41,18 @@ struct BodyFoldTests {
41 #expect(h.editor.hidden.all.map { $0.count } == hiddenBefore.map { $0.count }) 41 #expect(h.editor.hidden.all.map { $0.count } == hiddenBefore.map { $0.count })
42 } 42 }
43} 43}
44
45@MainActor
46struct OpenLinkTests {
47 @Test func commandsHandTheLinkToTheApp() {
48 let h = Harness("* A\nRead [[https://orgmode.org][the manual]] now.\n")
49 var requests: [ExternalRequest] = []
50 h.editor.onExternal = { requests.append($0) }
51 let inside = (h.string as NSString).range(of: "the manual").location
52 h.caret(at: inside)
53 h.editor.run("org.link.open")
54 h.editor.run("org.dwim")
55 let link = OrgLink(type: "https", path: "//orgmode.org", raw: "https://orgmode.org")
56 #expect(requests == [.openLink(link), .openLink(link)])
57 }
58}