Structure templates and editing blocks apart !79
7 files changed, +387 −1
Layout: unified · split
Sources/OrgCore/Commands/BlockCommands.swift added +193
| @@ -0,0 +1,193 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Blocks (Org 9.8.7): `org-insert-structure-template` (C-c C-,) with the default | ||
| 4 | // `org-structure-template-alist`, and `org-edit-special` (C-c ') on src, example and export | ||
| 5 | // blocks with Doom's `org-src-preserve-indentation` t: the block's lines as they are, commas | ||
| 6 | // that protect `*` and `#+` lines taken off while editing and put back after. | ||
| 7 | |||
| 8 | /// C-c C-,: `org-insert-structure-template`. Asks for a block type by key (Tab to type one), | ||
| 9 | /// then inserts an empty block, or wraps the selection. | ||
| 10 | public struct InsertStructureTemplate: OrgCommand { | ||
| 11 | public init() {} | ||
| 12 | public var id: String { "org.structure-template" } | ||
| 13 | public var title: String { "Insert Structure Template" } | ||
| 14 | public func applies(in context: EditContext) -> Bool { true } | ||
| 15 | |||
| 16 | /// `org-structure-template-alist`. | ||
| 17 | public static let templates: [(key: Character, type: String)] = [ | ||
| 18 | ("a", "export ascii"), ("c", "center"), ("C", "comment"), ("e", "example"), ("E", "export"), | ||
| 19 | ("h", "export html"), ("l", "export latex"), ("q", "quote"), ("s", "src"), ("v", "verse"), | ||
| 20 | ] | ||
| 21 | |||
| 22 | public func run(in context: EditContext) -> CommandStep { | ||
| 23 | guard let key = context.answers["template"] else { | ||
| 24 | let items = Self.templates.map { FastSelection.Item.option(key: $0.key, name: $0.type) } + [.option(key: "\t", name: "other (Tab)")] | ||
| 25 | return .prompt(Prompt(key: "template", message: "Insert block: [key] or Tab to type one", fast: FastSelection(items: items, multiple: false))) | ||
| 26 | } | ||
| 27 | let type: String | ||
| 28 | if key == "\t" { | ||
| 29 | guard let typed = context.answers["type"] else { return .prompt(Prompt(key: "type", message: "Structure type:")) } | ||
| 30 | guard !typed.isEmpty else { return .failed("Empty structure type") } | ||
| 31 | type = typed | ||
| 32 | } else { | ||
| 33 | guard let found = Self.templates.first(where: { String($0.key) == key }) else { return .failed("Quit") } | ||
| 34 | type = found.type | ||
| 35 | } | ||
| 36 | let region = context.selection.first.flatMap { $0.isEmpty ? nil : $0 } | ||
| 37 | return commitBuffer(context) { buffer in | ||
| 38 | try buffer.insertStructureTemplate(type, region: region) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | extension EmacsBuffer { | ||
| 44 | func insertStructureTemplate(_ type: String, region: Range<Int>?) throws { | ||
| 45 | let name = String(type.split(separator: " ").first ?? "") | ||
| 46 | let extended = type.range(of: "^(src|export)$", options: [.regularExpression, .caseInsensitive]) != nil | ||
| 47 | let verbatim = type.range(of: "^(example|export|src|comment)", options: [.regularExpression, .caseInsensitive]) != nil | ||
| 48 | let upcase = name == name.uppercased() | ||
| 49 | let regionEnd = region.map { marker($0.upperBound) } | ||
| 50 | defer { regionEnd.map(release) } | ||
| 51 | if let region { point = region.lowerBound } | ||
| 52 | let column = currentIndentation | ||
| 53 | let atIndentation = saveExcursion { () -> Bool in | ||
| 54 | skipBackward([32, 9]) | ||
| 55 | return bolp | ||
| 56 | } | ||
| 57 | if atIndentation { point = lineStart(point) } else { insert("\n") } | ||
| 58 | saveExcursion { | ||
| 59 | indentTo(column) | ||
| 60 | insert("#+\(upcase ? "BEGIN" : "begin")_\(type)\(extended ? " " : "")\n") | ||
| 61 | if let regionEnd { | ||
| 62 | if verbatim { escapeCode(from: point, to: regionEnd.position) } | ||
| 63 | point = regionEnd.position | ||
| 64 | // Without the blank lines at the region's end. | ||
| 65 | skipBackward([32, 13, 9, 10]) | ||
| 66 | if !eolp { | ||
| 67 | insert("\n") | ||
| 68 | forwardLine(-1) | ||
| 69 | } | ||
| 70 | point = lineEnd(point) | ||
| 71 | } | ||
| 72 | if !bolp { insert("\n") } | ||
| 73 | indentTo(column) | ||
| 74 | insert("#+\(upcase ? "END" : "end")_\(name)") | ||
| 75 | if let blank = lookingAt("[ \\t]*$"), let whole = blank[0] { | ||
| 76 | deleteRegion(whole.lowerBound, whole.upperBound) | ||
| 77 | } else { | ||
| 78 | insert("\n") | ||
| 79 | } | ||
| 80 | if eobp, !bolp { insert("\n") } | ||
| 81 | } | ||
| 82 | if extended { | ||
| 83 | point = lineEnd(point) | ||
| 84 | } else { | ||
| 85 | forwardLine(1) | ||
| 86 | skipForward([32, 9]) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | /// `org-escape-code-in-region`: a comma before lines that would read as headings or | ||
| 91 | /// keywords. | ||
| 92 | func escapeCode(from start: Int, to end: Int) { | ||
| 93 | let limit = marker(end) | ||
| 94 | defer { release(limit) } | ||
| 95 | point = start | ||
| 96 | while point < limit.position, let match = searchForward("^[ \\t]*(,*(?:\\*|#\\+))", bound: limit.position, caseInsensitive: false) { | ||
| 97 | point = match[1]!.lowerBound | ||
| 98 | insert(",") | ||
| 99 | point = lineEnd(point) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | /// A block's text, as `org-edit-special` hands it to an editing buffer. | ||
| 105 | public struct BlockEdit: Sendable, Equatable { | ||
| 106 | /// The lines between the opening and closing lines. | ||
| 107 | public let range: Range<Int> | ||
| 108 | /// Those lines as they were, to check nothing changed them meanwhile. | ||
| 109 | public let original: String | ||
| 110 | /// What to edit: the lines with their protecting commas taken off. | ||
| 111 | public let content: String | ||
| 112 | /// The src block's language, for highlighting. | ||
| 113 | public let language: String? | ||
| 114 | /// `src`, `example` or `export`. | ||
| 115 | public let kind: String | ||
| 116 | |||
| 117 | /// `org-unescape-code-in-string`. | ||
| 118 | public static func unescape(_ s: String) -> String { | ||
| 119 | s.replacingOccurrences(of: "(?m)^([ \\t]*),(,*(?:\\*|#\\+))", with: "$1$2", options: .regularExpression) | ||
| 120 | } | ||
| 121 | |||
| 122 | /// `org-escape-code-in-string`. | ||
| 123 | public static func escape(_ s: String) -> String { | ||
| 124 | s.replacingOccurrences(of: "(?m)^([ \\t]*)(,*(?:\\*|#\\+))", with: "$1,$2", options: .regularExpression) | ||
| 125 | } | ||
| 126 | |||
| 127 | /// The block's lines for edited `content`: escaped again, ending in a newline. | ||
| 128 | public func lines(for content: String) -> String { | ||
| 129 | guard !content.isEmpty else { return "" } | ||
| 130 | let escaped = Self.escape(content) | ||
| 131 | return escaped.hasSuffix("\n") ? escaped : escaped + "\n" | ||
| 132 | } | ||
| 133 | |||
| 134 | /// The src, example or export block whose lines hold `offset`, opening and closing | ||
| 135 | /// lines included. | ||
| 136 | public static func at(_ offset: Int, text: String, tree: OrgTree) -> BlockEdit? { | ||
| 137 | var node = tree.root | ||
| 138 | var found: SyntaxNode? | ||
| 139 | while let child = node.child(containing: offset) { | ||
| 140 | if child.kind == .block { found = child } | ||
| 141 | node = child | ||
| 142 | } | ||
| 143 | guard let block = found else { return nil } | ||
| 144 | let ns = text as NSString | ||
| 145 | let first = ns.lineRange(for: NSRange(location: block.range.lowerBound, length: 0)) | ||
| 146 | let opening = ns.substring(with: first) | ||
| 147 | guard let header = opening.firstMatch(of: /(?i)^[ \t]*#\+begin_(src|example|export)(?:[ \t]+([^ \t\n]+))?/) else { return nil } | ||
| 148 | guard let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { return nil } | ||
| 149 | let start = NSMaxRange(first) | ||
| 150 | let end = wrapper.closeLine | ||
| 151 | guard start <= end else { return nil } | ||
| 152 | let original = ns.substring(with: NSRange(location: start, length: end - start)) | ||
| 153 | let kind = header.1.lowercased() | ||
| 154 | return BlockEdit(range: start..<end, original: original, content: unescape(original), | ||
| 155 | language: kind == "src" ? header.2.map(String.init) : nil, kind: kind) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | /// C-c ': `org-edit-special` on a src, example or export block: the app edits its text apart, | ||
| 160 | /// then `ReplaceBlockLines` puts it back. | ||
| 161 | public struct EditSpecial: OrgCommand { | ||
| 162 | public init() {} | ||
| 163 | public var id: String { "org.edit-special" } | ||
| 164 | public var title: String { "Edit Block" } | ||
| 165 | public func applies(in context: EditContext) -> Bool { BlockEdit.at(context.caret, text: context.text, tree: context.tree) != nil } | ||
| 166 | public func run(in context: EditContext) -> CommandStep { | ||
| 167 | guard let edit = BlockEdit.at(context.caret, text: context.text, tree: context.tree) else { | ||
| 168 | return .failed("No special environment to edit here") | ||
| 169 | } | ||
| 170 | return .external(.editBlock(edit)) | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | /// Writes an edited block back, unless its lines changed meanwhile. | ||
| 175 | public struct ReplaceBlockLines: OrgCommand { | ||
| 176 | public let edit: BlockEdit | ||
| 177 | public let content: String | ||
| 178 | public init(edit: BlockEdit, content: String) { | ||
| 179 | self.edit = edit | ||
| 180 | self.content = content | ||
| 181 | } | ||
| 182 | public var id: String { "internal.replace-block" } | ||
| 183 | public var title: String { "Replace Block" } | ||
| 184 | public func applies(in context: EditContext) -> Bool { true } | ||
| 185 | public func run(in context: EditContext) -> CommandStep { | ||
| 186 | let ns = context.text as NSString | ||
| 187 | guard edit.range.upperBound <= ns.length, ns.substring(with: NSRange(edit.range)) == edit.original else { | ||
| 188 | return .failed("The block changed while you edited it; its edit wasn't saved.") | ||
| 189 | } | ||
| 190 | let lines = edit.lines(for: content) | ||
| 191 | return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)])) | ||
| 192 | } | ||
| 193 | } | ||
Sources/OrgCore/Commands/Command.swift +3 −1
| @@ -185,6 +185,8 @@ public enum ExternalRequest: Sendable, Equatable { | |||
| 185 | case babel(BabelJob) | 185 | case babel(BabelJob) |
| 186 | /// Follow a link: the app opens URLs and files and searches headings. | 186 | /// Follow a link: the app opens URLs and files and searches headings. |
| 187 | case openLink(OrgLink) | 187 | case openLink(OrgLink) |
| 188 | /// Edit a block's text apart (`org-edit-special`); `ReplaceBlockLines` puts it back. | ||
| 189 | case editBlock(BlockEdit) | ||
| 188 | } | 190 | } |
| 189 | 191 | ||
| 190 | /// A named operation. Keys, menus, the palette and touch controls all run commands. | 192 | /// A named operation. Keys, menus, the palette and touch controls all run commands. |
| @@ -213,7 +215,7 @@ public enum Commands { | |||
| 213 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), | 215 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), |
| 214 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 216 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 215 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), | 217 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 216 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), | 218 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), |
| 217 | ] | 219 | ] |
| 218 | 220 | ||
| 219 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } | 221 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } |
Sources/OrgCore/Keymap/Presets.swift +10
| @@ -190,6 +190,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 190 | 190 | ||
| 191 | # Localleader (SPC m) | 191 | # Localleader (SPC m) |
| 192 | [[bind]] | 192 | [[bind]] |
| 193 | keys = "SPC m '" | ||
| 194 | command = "org.edit-special" | ||
| 195 | mode = "normal" | ||
| 196 | [[bind]] | ||
| 193 | keys = "SPC m s c" | 197 | keys = "SPC m s c" |
| 194 | command = "org.subtree.clone" | 198 | command = "org.subtree.clone" |
| 195 | mode = "normal" | 199 | mode = "normal" |
| @@ -627,6 +631,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 627 | keys = "C-c ^" | 631 | keys = "C-c ^" |
| 628 | command = "org.sort" | 632 | command = "org.sort" |
| 629 | [[bind]] | 633 | [[bind]] |
| 634 | keys = "C-c C-," | ||
| 635 | command = "org.structure-template" | ||
| 636 | [[bind]] | ||
| 637 | keys = "C-c '" | ||
| 638 | command = "org.edit-special" | ||
| 639 | [[bind]] | ||
| 630 | keys = "C-x b" | 640 | keys = "C-x b" |
| 631 | command = "app.buffer.switch" | 641 | command = "app.buffer.switch" |
| 632 | [[bind]] | 642 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +2
| @@ -41,6 +41,8 @@ public enum EditorCommands { | |||
| 41 | EditorCommand(id: "org.link.store", title: "Store Link", action: .app), | 41 | EditorCommand(id: "org.link.store", title: "Store Link", action: .app), |
| 42 | EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app), | 42 | EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app), |
| 43 | EditorCommand(id: "org.subtree.paste", title: "Paste Subtree", action: .app), | 43 | EditorCommand(id: "org.subtree.paste", title: "Paste Subtree", action: .app), |
| 44 | EditorCommand(id: "app.block.save", title: "Save Block", action: .app), | ||
| 45 | EditorCommand(id: "app.block.cancel", title: "Leave Block", action: .app), | ||
| 44 | 46 | ||
| 45 | EditorCommand(id: "app.save", title: "Save", action: .app), | 47 | EditorCommand(id: "app.save", title: "Save", action: .app), |
| 46 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), | 48 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), |
Sources/Orgstar/BlockEditorView.swift added +65
| @@ -0,0 +1,65 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgCore | ||
| 3 | import OrgDocument | ||
| 4 | import OrgEditorAppKit | ||
| 5 | import SwiftUI | ||
| 6 | |||
| 7 | /// `org-edit-special` for a block: its text in an editor of its own, highlighted as its | ||
| 8 | /// language. ⌘Return or C-c ' puts it back; Escape or C-c C-k leaves it. | ||
| 9 | struct BlockEditorView: View { | ||
| 10 | let edit: BlockEdit | ||
| 11 | let finish: (String?) -> Void | ||
| 12 | @State private var box = EditorBox() | ||
| 13 | |||
| 14 | var body: some View { | ||
| 15 | VStack(alignment: .leading, spacing: 8) { | ||
| 16 | Text("Editing \(edit.language.map { "\($0) " } ?? "")\(edit.kind) block") | ||
| 17 | .font(.headline) | ||
| 18 | CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) } | ||
| 19 | .frame(minWidth: 560, minHeight: 320) | ||
| 20 | .border(.separator) | ||
| 21 | HStack { | ||
| 22 | Text("C-c ' or ⌘Return saves, Escape or C-c C-k leaves").font(.caption).foregroundStyle(.secondary) | ||
| 23 | Spacer() | ||
| 24 | Button("Cancel", role: .cancel) { finish(nil) } | ||
| 25 | .keyboardShortcut(.cancelAction) | ||
| 26 | Button("Save Block") { finish(box.text) } | ||
| 27 | .keyboardShortcut(.return, modifiers: .command) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | .padding(16) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Holds the editor so the view can read its text when saving. | ||
| 35 | @MainActor | ||
| 36 | final class EditorBox { | ||
| 37 | var editor: OrgEditor? | ||
| 38 | var text: String { editor?.document.text ?? "" } | ||
| 39 | } | ||
| 40 | |||
| 41 | /// An `OrgEditor` in plain mode for the block's language. | ||
| 42 | struct CodeEditor: NSViewRepresentable { | ||
| 43 | let text: String | ||
| 44 | let language: String? | ||
| 45 | let box: EditorBox | ||
| 46 | let done: (Bool) -> Void | ||
| 47 | |||
| 48 | func makeNSView(context: Context) -> NSScrollView { | ||
| 49 | let editor = OrgEditor(document: DocumentState(bytes: [UInt8](text.utf8)), highlighter: EditorView.highlighter, mode: .plain(language: language)) | ||
| 50 | editor.keymap = Keymap([ | ||
| 51 | KeyBinding(keys: KeySequence.parse("C-c '")!, command: "app.block.save"), | ||
| 52 | KeyBinding(keys: KeySequence.parse("C-c C-k")!, command: "app.block.cancel"), | ||
| 53 | ]) | ||
| 54 | editor.onAppCommand = { id in | ||
| 55 | if id == "app.block.save" { done(true) } | ||
| 56 | if id == "app.block.cancel" { done(false) } | ||
| 57 | } | ||
| 58 | box.editor = editor | ||
| 59 | let scroll = editor.makeScrollView() | ||
| 60 | DispatchQueue.main.async { scroll.window?.makeFirstResponder(editor.textView) } | ||
| 61 | return scroll | ||
| 62 | } | ||
| 63 | |||
| 64 | func updateNSView(_ view: NSScrollView, context: Context) {} | ||
| 65 | } | ||
Sources/Orgstar/ContentView.swift +17
| @@ -46,6 +46,9 @@ struct ContentView: View { | |||
| 46 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup | 46 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup |
| 47 | ) | 47 | ) |
| 48 | } | 48 | } |
| 49 | /// A block being edited apart (`org-edit-special`). | ||
| 50 | @State private var blockEdit: BlockEditRequest? | ||
| 51 | |||
| 49 | @State private var selectedFile: String? | 52 | @State private var selectedFile: String? |
| 50 | /// A file that isn't text, shown as a preview instead of the open document. | 53 | /// A file that isn't text, shown as a preview instead of the open document. |
| 51 | @State private var previewPath: String? | 54 | @State private var previewPath: String? |
| @@ -200,6 +203,13 @@ struct ContentView: View { | |||
| 200 | .onChange(of: options, initial: true) { _, options in session.options = options } | 203 | .onChange(of: options, initial: true) { _, options in session.options = options } |
| 201 | .onChange(of: preset, initial: true) { loadKeymap() } | 204 | .onChange(of: preset, initial: true) { loadKeymap() } |
| 202 | .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) } | 205 | .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) } |
| 206 | .sheet(item: $blockEdit) { request in | ||
| 207 | BlockEditorView(edit: request.edit) { content in | ||
| 208 | blockEdit = nil | ||
| 209 | guard let content else { return } | ||
| 210 | commands.editor?.perform(ReplaceBlockLines(edit: request.edit, content: content)) | ||
| 211 | } | ||
| 212 | } | ||
| 203 | .sheet(isPresented: $commands.showingPalette) { | 213 | .sheet(isPresented: $commands.showingPalette) { |
| 204 | PaletteView(commands: commands) { id in | 214 | PaletteView(commands: commands) { id in |
| 205 | commands.showingPalette = false | 215 | commands.showingPalette = false |
| @@ -353,6 +363,8 @@ struct ContentView: View { | |||
| 353 | switch request { | 363 | switch request { |
| 354 | case .openLink(let link): | 364 | case .openLink(let link): |
| 355 | if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } | 365 | if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } |
| 366 | case .editBlock(let edit): | ||
| 367 | blockEdit = BlockEditRequest(edit: edit) | ||
| 356 | case .emacsTable(let table, let point, let form, let reason, let lisp): | 368 | case .emacsTable(let table, let point, let form, let reason, let lisp): |
| 357 | let run = { | 369 | let run = { |
| 358 | commands.show("Recalculating in Emacs (\(reason))…") | 370 | commands.show("Recalculating in Emacs (\(reason))…") |
| @@ -804,3 +816,8 @@ struct BufferTabs: View { | |||
| 804 | .help(path) | 816 | .help(path) |
| 805 | } | 817 | } |
| 806 | } | 818 | } |
| 819 | |||
| 820 | struct BlockEditRequest: Identifiable { | ||
| 821 | let id = UUID() | ||
| 822 | let edit: BlockEdit | ||
| 823 | } | ||
Tests/OrgCoreTests/BlockCommandTests.swift added +97
| @@ -0,0 +1,97 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct BlockCommandTests { | ||
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | ||
| 7 | |||
| 8 | static let text = """ | ||
| 9 | * Heading | ||
| 10 | Some text here. | ||
| 11 | Indented line | ||
| 12 | * Starred | ||
| 13 | #+keyword | ||
| 14 | last | ||
| 15 | """ | ||
| 16 | |||
| 17 | @Test(.enabled(if: oracle)) | ||
| 18 | func insertsTemplatesLikeOrg() throws { | ||
| 19 | let ns = Self.text as NSString | ||
| 20 | let points = [ns.range(of: "Some").location, ns.range(of: "text").location, ns.range(of: "Indented").location, ns.length] | ||
| 21 | let regions = [("Some", "here."), ("Indented", "#+keyword"), ("Some", "line\n")] | ||
| 22 | let types = ["src", "quote", "EXAMPLE", "export html", "verse"] | ||
| 23 | var cases: [EmacsOracle.Case] = [] | ||
| 24 | var ours: [(String, Int)] = [] | ||
| 25 | for type in types { | ||
| 26 | for offset in points { | ||
| 27 | cases.append(EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(offset, in: Self.text), form: "(org-insert-structure-template \(lispString(type)))")) | ||
| 28 | ours.append(Self.run(type, offset..<offset)) | ||
| 29 | } | ||
| 30 | for (from, to) in regions { | ||
| 31 | let start = ns.range(of: from).location | ||
| 32 | let end = NSMaxRange(ns.range(of: to)) | ||
| 33 | cases.append(EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(end, in: Self.text), form: "(progn (transient-mark-mode 1) (set-mark \(EmacsOracle.point(start, in: Self.text))) (activate-mark) (org-insert-structure-template \(lispString(type))))")) | ||
| 34 | ours.append(Self.run(type, start..<end)) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() { | ||
| 38 | #expect(mine.0 == theirs.text, "\(cases[i].form)\nours: \(mine.0.debugDescription)\nemacs: \(theirs.text.debugDescription)") | ||
| 39 | if mine.0 == theirs.text { #expect(mine.1 == EmacsOracle.offset(theirs.point, in: theirs.text), "\(cases[i].form) caret") } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | static func run(_ type: String, _ selection: Range<Int>) -> (String, Int) { | ||
| 44 | let key = InsertStructureTemplate.templates.first { $0.type == type }.map { String($0.key) } ?? "\t" | ||
| 45 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [selection], answers: ["template": key, "type": type]) | ||
| 46 | guard case .commit(let result) = InsertStructureTemplate().run(in: context) else { return (text, 0) } | ||
| 47 | var new = text | ||
| 48 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) } | ||
| 49 | return (new, result.selection?.first?.lowerBound ?? 0) | ||
| 50 | } | ||
| 51 | |||
| 52 | static let blocks = """ | ||
| 53 | * Code | ||
| 54 | #+begin_src python :results output | ||
| 55 | def f(): | ||
| 56 | return 1 | ||
| 57 | ,* not a heading | ||
| 58 | ,#+not a keyword | ||
| 59 | #+end_src | ||
| 60 | #+begin_example | ||
| 61 | indented | ||
| 62 | #+end_example | ||
| 63 | #+begin_src sh | ||
| 64 | #+end_src | ||
| 65 | """ | ||
| 66 | |||
| 67 | @Test(.enabled(if: oracle)) | ||
| 68 | func editsBlocksLikeOrgEditSpecial() throws { | ||
| 69 | let ns = Self.blocks as NSString | ||
| 70 | let points = [ns.range(of: "def f").location, ns.range(of: "#+begin_example").location, ns.range(of: "#+begin_src sh").location + 3] | ||
| 71 | let added = "* new star\n#+new keyword\nplain" | ||
| 72 | let forms = points.map { point in | ||
| 73 | """ | ||
| 74 | (progn (erase-buffer) (insert \(lispString(Self.blocks))) (goto-char \(EmacsOracle.point(point, in: Self.blocks))) | ||
| 75 | (org-edit-special) | ||
| 76 | (let ((content (buffer-string))) | ||
| 77 | (goto-char (point-max)) (insert \(lispString(added))) | ||
| 78 | (org-edit-src-exit) | ||
| 79 | (format "%s|%s" content (buffer-string)))) | ||
| 80 | """ | ||
| 81 | } | ||
| 82 | let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(setq org-src-preserve-indentation t org-src-window-setup 'current-window)") | ||
| 83 | let tree = OrgParser.parse(Self.blocks) | ||
| 84 | for (point, expected) in zip(points, emacs) { | ||
| 85 | guard let edit = BlockEdit.at(point, text: Self.blocks, tree: tree) else { | ||
| 86 | Issue.record("no block at \(point)") | ||
| 87 | continue | ||
| 88 | } | ||
| 89 | let context = EditContext(revision: 0, text: Self.blocks, tree: tree, selection: [point..<point]) | ||
| 90 | var after = Self.blocks | ||
| 91 | if case .commit(let result) = ReplaceBlockLines(edit: edit, content: edit.content + added).run(in: context) { | ||
| 92 | for e in result.edits { after = e.apply(to: after) } | ||
| 93 | } | ||
| 94 | #expect("\(edit.content)|\(after)" == expected, "at \(point)") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||