Commit 2cd54205f3
Verified · cmc
Layout: unified · split
Sources/OrgApp/LinkFollow.swift added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | /// What following a link comes to. | |
| 5 | public 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 | ||
| 13 | extension 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 | 148 | case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool) |
| 149 | 149 | /// Run a src block; `Babel.finish` turns its output into the result edit. |
| 150 | 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 | 155 | /// A named operation. Keys, menus, the palette and touch controls all run commands. |
| @@ -175,6 +177,7 @@ public enum Commands { | ||
| 175 | 177 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), |
| 176 | 178 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), |
| 177 | 179 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 180 | OpenLinkAtPoint(), | |
| 178 | 181 | ] |
| 179 | 182 | |
| 180 | 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 | 12 | public func applies(in context: EditContext) -> Bool { true } |
| 13 | 13 | |
| 14 | 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 | 17 | if TableRecalculate.tblfmLine(in: context) != nil { return TableRecalculate(all: true).run(in: context) } |
| 16 | 18 | if KeyContext.holds("src", in: context) { return ExecuteSrcBlock().run(in: context) } |
| 17 | 19 | if KeyContext.holds("table", in: context) { |
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -551,6 +551,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 551 | 551 | keys = "C-x C-s" |
| 552 | 552 | command = "app.save" |
| 553 | 553 | [[bind]] |
| 554 | keys = "C-c C-o" | |
| 555 | command = "org.link.open" | |
| 556 | [[bind]] | |
| 554 | 557 | keys = "C-x b" |
| 555 | 558 | command = "app.buffer.switch" |
| 556 | 559 | [[bind]] |
Sources/OrgCore/Links/Links.swift added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A link as `org-element-link-parser` reads it. | |
| 4 | public 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 | ||
| 21 | public 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). | |
| 202 | public 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 | 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 | 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 | 91 | onMouseDown?() |
| 85 | 92 | super.mouseDown(with: event) |
| 86 | 93 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +6
| @@ -112,6 +112,12 @@ public final class OrgEditor: NSObject { | ||
| 112 | 112 | dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) |
| 113 | 113 | super.init() |
| 114 | 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 | 121 | orgTextView.onMouseDown = { [weak self] in |
| 116 | 122 | self?.markActive = false |
| 117 | 123 | self?.modalMouseDown() |
Sources/Orgstar/ContentView.swift +2
| @@ -342,6 +342,8 @@ struct ContentView: View { | ||
| 342 | 342 | /// Work a command hands to the platform: tables recalculated in Emacs. |
| 343 | 343 | private func runExternal(_ request: ExternalRequest) { |
| 344 | 344 | switch request { |
| 345 | case .openLink(let link): | |
| 346 | if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } | |
| 345 | 347 | case .emacsTable(let table, let point, let form, let reason, let lisp): |
| 346 | 348 | let run = { |
| 347 | 349 | commands.show("Recalculating in Emacs (\(reason))…") |
Sources/Orgstar/LinkOpener.swift added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | import AppKit | |
| 2 | import OrgApp | |
| 3 | import OrgCore | |
| 4 | ||
| 5 | /// Follows a link, handing addresses and files Orgstar doesn't edit to macOS. | |
| 6 | @MainActor | |
| 7 | enum 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 | 732 | #expect(session.openDocuments.first { $0.path == a.path }?.document.text == "* A changed\n") |
| 733 | 733 | } |
| 734 | 734 | } |
| 735 | ||
| 736 | @MainActor | |
| 737 | struct 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 | 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 | 132 | /// UTF-16 offset to Emacs point. |
| 104 | 133 | static func point(_ offset: Int, in text: String) -> Int { |
| 105 | 134 | let index = String.Index(utf16Offset: offset, in: text) |
Tests/OrgCoreTests/LinkTests.swift added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | 41 | #expect(h.editor.hidden.all.map { $0.count } == hiddenBefore.map { $0.count }) |
| 42 | 42 | } |
| 43 | 43 | } |
| 44 | ||
| 45 | @MainActor | |
| 46 | struct 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 | } | |