krz/orgstar

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

Tests/OrgCoreTests/LinkTests.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Tests/OrgCoreTests/LinkTests.swift history · blame · raw

167 lines · 7918 bytes

  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}