Sources/OrgCore/Commands/BlockCommands.swift
212 lines · 9728 bytes
1import 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.
10public 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
43extension 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.
105public 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`; `formulas` or `field` for a table's.
115 public let kind: String
116 /// A table field's point and coordinate (`@2$3`).
117 public var point: Int?
118 public var coordinate: String?
119
120 /// What the editor is editing, for its title.
121 public var title: String {
122 switch kind {
123 case Self.formulasKind: "Editing table formulas"
124 case Self.fieldKind: "Editing field \(coordinate ?? "")"
125 default: "Editing \(language.map { "\($0) " } ?? "")\(kind) block"
126 }
127 }
128
129 /// `org-unescape-code-in-string`.
130 public static func unescape(_ s: String) -> String {
131 s.replacingOccurrences(of: "(?m)^([ \\t]*),(,*(?:\\*|#\\+))", with: "$1$2", options: .regularExpression)
132 }
133
134 /// `org-escape-code-in-string`.
135 public static func escape(_ s: String) -> String {
136 s.replacingOccurrences(of: "(?m)^([ \\t]*)(,*(?:\\*|#\\+))", with: "$1,$2", options: .regularExpression)
137 }
138
139 /// The block's lines for edited `content`: escaped again, ending in a newline.
140 public func lines(for content: String) -> String {
141 guard !content.isEmpty else { return "" }
142 let escaped = Self.escape(content)
143 return escaped.hasSuffix("\n") ? escaped : escaped + "\n"
144 }
145
146 /// The src, example or export block whose lines hold `offset`, opening and closing
147 /// lines included.
148 public static func at(_ offset: Int, text: String, tree: OrgTree) -> BlockEdit? {
149 var node = tree.root
150 var found: SyntaxNode?
151 while let child = node.child(containing: offset) {
152 if child.kind == .block { found = child }
153 node = child
154 }
155 guard let block = found else { return nil }
156 let ns = text as NSString
157 let first = ns.lineRange(for: NSRange(location: block.range.lowerBound, length: 0))
158 let opening = ns.substring(with: first)
159 guard let header = opening.firstMatch(of: /(?i)^[ \t]*#\+begin_(src|example|export)(?:[ \t]+([^ \t\n]+))?/) else { return nil }
160 guard let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { return nil }
161 let start = NSMaxRange(first)
162 let end = wrapper.closeLine
163 guard start <= end else { return nil }
164 let original = ns.substring(with: NSRange(location: start, length: end - start))
165 let kind = header.1.lowercased()
166 return BlockEdit(range: start..<end, original: original, content: unescape(original),
167 language: kind == "src" ? header.2.map(String.init) : nil, kind: kind)
168 }
169}
170
171/// C-c ': `org-edit-special` on a src, example or export block: the app edits its text apart,
172/// then `ReplaceBlockLines` puts it back.
173public struct EditSpecial: OrgCommand {
174 public init() {}
175 public var id: String { "org.edit-special" }
176 public var title: String { "Edit Block" }
177 public func applies(in context: EditContext) -> Bool { edit(context) != nil }
178
179 private func edit(_ context: EditContext) -> BlockEdit? {
180 BlockEdit.at(context.caret, text: context.text, tree: context.tree) ?? BlockEdit.formulas(at: context.caret, text: context.text)
181 }
182
183 public func run(in context: EditContext) -> CommandStep {
184 guard let edit = edit(context) else {
185 return .failed("No special environment to edit here")
186 }
187 return .external(.editBlock(edit))
188 }
189}
190
191/// Writes an edited block back, unless its lines changed meanwhile.
192public struct ReplaceBlockLines: OrgCommand {
193 public let edit: BlockEdit
194 public let content: String
195 public init(edit: BlockEdit, content: String) {
196 self.edit = edit
197 self.content = content
198 }
199 public var id: String { "internal.replace-block" }
200 public var title: String { "Replace Block" }
201 public func applies(in context: EditContext) -> Bool { true }
202 public func run(in context: EditContext) -> CommandStep {
203 let ns = context.text as NSString
204 guard edit.range.upperBound <= ns.length, ns.substring(with: NSRange(edit.range)) == edit.original else {
205 return .failed("The block changed while you edited it; its edit wasn't saved.")
206 }
207 if edit.kind == BlockEdit.formulasKind { return edit.installFormulas(content, in: context) }
208 if edit.kind == BlockEdit.fieldKind { return edit.installField(content, in: context) }
209 let lines = edit.lines(for: content)
210 return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)]))
211 }
212}