krz/orgstar

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

Sources/OrgCore/Commands/BlockCommands.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgCore/Commands/BlockCommands.swift history · blame · raw

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}