Tests/OrgCoreTests/LinkTests.swift
167 lines · 7918 bytes
6 symbols in this file
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
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 }
167}