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 | 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 @@ | |||
| 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 | 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 @@ | |||
| 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 | #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 | ||
| 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 | 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 @@ | |||
| 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 | #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 | ||
| 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 | } | ||