krz/orgstar

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

Sources/OrgCore/Commands/Table.swift

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

668 lines · 28031 bytes

  1import Foundation
  2
  3// Org tables (org-table.el, Org 9.8.7): alignment and the editing commands, with
  4// `org-table-automatic-realign` t, `org-table-tab-jumps-over-hlines` t,
  5// `org-table-fix-formulas-confirm` nil (formulas are fixed silently). Shrunk columns are only
  6// drawn (`TableShrink`); these commands see the full text.
  7
  8extension EmacsBuffer {
  9    static let numberPattern = "^([<>]?[-+^.0-9]*[0-9][-+^.0-9eEdDx()%:]*|[<>]?[-+]?0[xX][0-9a-fA-F.]+|[<>]?[-+]?[0-9]+#[0-9a-zA-Z.]+|nan|[-+u]?inf)$"
 10
 11    /// `org-at-table-p`: an org table line outside blocks.
 12    var atTable: Bool {
 13        guard lookingAtLine("^[ \\t]*\\|") else { return false }
 14        // Dynamic blocks hold elements in Org; their tables count.
 15        let kind = element(at: lineStart(point))?.kind
 16        return kind == .table || kind == .tableRow || kind == .dynamicBlock
 17    }
 18
 19    var atTableHline: Bool { lookingAtLine("^[ \\t]*\\|-") }
 20
 21    func lookingAtLine(_ pattern: String) -> Bool {
 22        saveExcursion {
 23            beginningOfLine()
 24            return looking(pattern, caseInsensitive: false, setsMatch: false) != nil
 25        }
 26    }
 27
 28    /// `org-table-begin`.
 29    func tableBegin() -> Int {
 30        saveExcursion {
 31            searchBackward("^[ \\t]*[^| \\t]", caseInsensitive: false).map { _ in lineEnd(point) + 1 } ?? 0
 32        }
 33    }
 34
 35    /// `org-table-end`.
 36    func tableEnd() -> Int {
 37        saveExcursion {
 38            if let match = searchForward("^[ \\t]*[^| \\t]", caseInsensitive: false) { return match[0]!.lowerBound }
 39            point = length
 40            skipBackward(Self.blanks)
 41            return bolp ? point : lineEnd(point)
 42        }
 43    }
 44
 45    /// `org-table-to-lisp`: rows of trimmed fields, nil for rules.
 46    func tableToLisp() -> [[String]?] {
 47        saveExcursion {
 48            point = tableBegin()
 49            var table: [[String]?] = []
 50            while true {
 51                skipForward(Self.blanks)
 52                guard character(at: point) == 124 else { break }
 53                point += 1
 54                if character(at: point) == 45 {
 55                    table.append(nil)
 56                } else {
 57                    var row: [String] = []
 58                    while true {
 59                        skipForward(Self.blanks)
 60                        if eolp { break }
 61                        let start = point
 62                        while let c = character(at: point), c != 124, c != 10 { point += 1 }
 63                        let next = eolp ? point : point + 1
 64                        skipBackward(Self.blanks, bound: start)
 65                        row.append(substring(start, point))
 66                        point = next
 67                    }
 68                    table.append(row)
 69                }
 70                if forwardLine(1) != 0 { break }
 71            }
 72            return table
 73        }
 74    }
 75
 76    /// `org-string-width` of a cell as Emacs shows it.
 77    func cellWidth(_ cell: String) -> Int {
 78        VisibleLine(cell, options: options, settings: settings).width
 79    }
 80
 81    /// `org-table--align-field`.
 82    func alignField(_ field: String, width: Int, alignment: Character, fieldWidth: Int) -> String {
 83        let spaces = width - fieldWidth
 84        let prefix: Int
 85        switch alignment {
 86        case "r": prefix = spaces
 87        case "c": prefix = spaces / 2
 88        default: prefix = 0
 89        }
 90        return " " + String(repeating: " ", count: prefix) + field + String(repeating: " ", count: spaces - prefix) + " "
 91    }
 92
 93    /// `org-table-align`, keeping point in the same field.
 94    func tableAlign() {
 95        let begin = tableBegin()
 96        let end = marker(tableEnd())
 97        defer { release(end) }
 98        let line = marker(lineStart(point))
 99        defer { release(line) }
100        let column = tableCurrentColumn()
101        point = begin
102        let table = tableToLisp()
103        let rows = table.compactMap { $0 }
104        var widths: [Int] = []
105        var alignments: [Character] = []
106        var widthCache: [String: Int] = [:]
107        func width(_ cell: String) -> Int {
108            if let cached = widthCache[cell] { return cached }
109            let value = cellWidth(cell)
110            widthCache[cell] = value
111            return value
112        }
113        let numberRegex = Self.regex(Self.numberPattern, caseInsensitive: false)
114        var columns = 1
115        if rows.isEmpty {
116            let lineEndOffset = lineEnd(point)
117            columns += substring(point, lineEndOffset).filter { $0 == "+" }.count
118            widths = Array(repeating: 1, count: columns)
119            alignments = Array(repeating: "l", count: columns)
120        } else {
121            columns = rows.map(\.count).max() ?? 1
122            for i in 0..<columns {
123                var maxWidth = 1
124                var fixed: Character?
125                var numbers = 0, nonEmpty = 0
126                for row in rows {
127                    let cell = i < row.count ? row[i] : ""
128                    maxWidth = max(maxWidth, width(cell))
129                    if fixed != nil || cell.isEmpty { continue }
130                    if let cookie = cell.firstMatch(of: /^<([lrc])[0-9]*>$/) {
131                        fixed = cookie.1.first
132                    } else {
133                        nonEmpty += 1
134                        if numberRegex.firstMatch(in: cell, range: NSRange(location: 0, length: (cell as NSString).length)) != nil { numbers += 1 }
135                    }
136                }
137                widths.append(maxWidth)
138                alignments.append(fixed ?? (Double(numbers) >= 0.5 * Double(nonEmpty) ? "r" : "l"))
139            }
140        }
141        let rule = widths.map { String(repeating: "-", count: $0 + 2) }.joined(separator: "+")
142        let indent = looking("[ \\t]*\\|", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) } ?? "|"
143        for row in table {
144            let previous = substring(point, lineEnd(point))
145            let new: String
146            if let row {
147                let fields = row + Array(repeating: "", count: max(0, columns - row.count))
148                new = indent + zip(fields, zip(widths, alignments)).map { field, spec in
149                    alignField(field, width: spec.0, alignment: spec.1, fieldWidth: width(field))
150                }.joined(separator: "|") + "|"
151            } else {
152                new = indent + rule + "|"
153            }
154            if new == previous {
155                forwardLine(1)
156            } else {
157                insert(new + "\n")
158                deleteRegion(point, min(length, lineEnd(point) + 1))
159            }
160        }
161        point = line.position
162        tableGotoColumn(column)
163    }
164
165    /// `org-table-current-column`.
166    func tableCurrentColumn() -> Int {
167        saveExcursion {
168            let pos = point
169            beginningOfLine()
170            let text = substring(point, pos)
171            guard let first = text.firstIndex(of: "|") else { return 0 }
172            let separators: Set<Character> = atTableHline ? ["+", "|"] : ["|"]
173            return 1 + text[text.index(after: first)...].filter { separators.contains($0) }.count
174        }
175    }
176
177    /// `org-table-goto-column`.
178    func tableGotoColumn(_ n: Int, onDelim: Bool = false, force: Bool = false) {
179        beginningOfLine()
180        guard n > 0 else { return }
181        var remaining = n
182        while remaining > 0 {
183            remaining -= 1
184            if let match = searchForward("\\|", bound: lineEnd(point), caseInsensitive: false) {
185                _ = match
186            } else if force {
187                endOfLine()
188                skipBackwardNot([124])
189                insert(" | ")
190            } else {
191                break
192            }
193        }
194        if force, looking(".*\\|", caseInsensitive: false, setsMatch: false) == nil {
195            saveExcursion {
196                endOfLine()
197                insert(" | ")
198            }
199        }
200        if onDelim {
201            point -= 1
202        } else if character(at: point) == 32 {
203            point += 1
204        }
205    }
206
207    /// `skip-chars-backward "^|"`.
208    func skipBackwardNot(_ set: Set<unichar>) {
209        while point > lineStart(point), let c = character(at: point - 1), !set.contains(c) { point -= 1 }
210    }
211
212    /// `org-table-current-dline`.
213    func tableCurrentDataLine() -> Int {
214        saveExcursion {
215            let pos = lineStart(point)
216            point = tableBegin()
217            var count = 0
218            while point <= pos {
219                if looking("^[ \\t]*\\|[^-]", caseInsensitive: false, setsMatch: false) != nil { count += 1 }
220                if forwardLine(1) != 0 { break }
221            }
222            return count
223        }
224    }
225
226    /// `org-table-clean-line`.
227    func cleanTableLine(_ line: String) -> String {
228        if line.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil {
229            return String(line.map { $0 == "|" || $0 == "+" ? "|" : " " })
230        }
231        var result = line
232        let regex = try! NSRegularExpression(pattern: "\\|([ \\t]*?[^ \\t\\r\\n|][^\\r\\n|]*)\\|")
233        while let match = regex.firstMatch(in: result, range: NSRange(location: 0, length: (result as NSString).length)) {
234            let cell = (result as NSString).substring(with: match.range(at: 1))
235            result = (result as NSString).replacingCharacters(in: match.range, with: "|" + String(repeating: " ", count: cellWidth(cell)) + "|")
236        }
237        return result
238    }
239
240    /// `org-table-insert-row`; `below` as with a prefix argument.
241    func tableInsertRow(below: Bool) throws {
242        guard atTable else { throw UserError(message: "Not at a table") }
243        if eobp { saveExcursion { insert("\n") } }
244        if substring(lineStart(point), lineEnd(point)).range(of: "\\|[ \\t]*$", options: .regularExpression) == nil { tableAlign() }
245        let line = substring(lineStart(point), lineEnd(point))
246        var new = cleanTableLine(line)
247        if let match = line.range(of: "^[ \\t]*\\| *[#*$] *\\|", options: .regularExpression), let target = new.range(of: "^[ \\t]*\\| *[#*$] *\\|", options: .regularExpression) ?? new.range(of: "^[ \\t]*\\|[^|]*\\|", options: .regularExpression) {
248            new.replaceSubrange(target, with: line[match])
249        }
250        forwardLine(below ? 1 : 0)
251        if !bolp { insert("\n") }
252        insertBeforeMarkers(new + "\n")
253        forwardLine(-1)
254        searchForward("\\| ?", bound: lineEnd(point), caseInsensitive: false)
255        // `org-table-may-need-update' is set by any edit to a table, so org realigns here.
256        tableAlign()
257        fixFormulas("@", replace: [:], limit: tableCurrentDataLine() - 1, delta: 1)
258    }
259
260    /// `org-table-insert-hline`; `above` as with a prefix argument.
261    func tableInsertHline(above: Bool) throws {
262        guard atTable else { throw UserError(message: "Not at a table") }
263        if eobp { saveExcursion { insert("\n") } }
264        if substring(lineStart(point), lineEnd(point)).range(of: "\\|[ \\t]*$", options: .regularExpression) == nil { tableAlign() }
265        var line = cleanTableLine(substring(lineStart(point), lineEnd(point)))
266        let column = currentColumn
267        let regex = try! NSRegularExpression(pattern: "\\|( +)\\|")
268        while let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) {
269            line = (line as NSString).replacingCharacters(in: match.range, with: "+" + String(repeating: "-", count: match.range(at: 1).length) + "|")
270        }
271        if let plus = line.firstIndex(of: "+") { line.replaceSubrange(plus...plus, with: "|") }
272        forwardLine(above ? 0 : 1)
273        insert(line + "\n")
274        forwardLine(above ? 0 : -2)
275        moveToColumn(column)
276    }
277
278    /// What `org-table-maybe-eval-formula` does to point: `org-table-get-field` leaves it one
279    /// character into the field.
280    func moveIntoField() {
281        skipBackwardNot([124, 10])
282        if bolp || looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil { return }
283        let start = point
284        point = min(lineEnd(point), start + 1)
285    }
286
287    /// `org-table-next-field`.
288    func tableNextField() throws {
289        try tableMaybeEvalFormula()
290        moveIntoField()
291        tableAlign()
292        let end = tableEnd()
293        if atTableHline { endOfLine() }
294        func toNextBar() -> Bool { searchForward("\\|", bound: end, caseInsensitive: false) != nil }
295        var ok = toNextBar()
296        if ok, looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil { ok = toNextBar() }
297        guard ok else {
298            try tableInsertRow(below: true)
299            return
300        }
301        if looking("-", caseInsensitive: false, setsMatch: false) != nil, let match = searchForward("^[ \\t]*\\|([^-])", bound: end, caseInsensitive: false) {
302            point = match[1]!.lowerBound
303        }
304        if looking("-", caseInsensitive: false, setsMatch: false) != nil {
305            forwardLine(-1)
306            try tableInsertRow(below: true)
307        } else if character(at: point) == 32 {
308            point += 1
309        }
310    }
311
312    /// `org-table-previous-field`.
313    func tablePreviousField() throws {
314        tableAlign()
315        if atTableHline { endOfLine() }
316        let start = tableBegin()
317        let origin = point
318        func back() -> Bool {
319            guard let match = searchBackward("\\|", bound: start, caseInsensitive: false) else { return false }
320            point = match[0]!.lowerBound
321            return true
322        }
323        var ok = back() && back()
324        while ok, looking("\\|(?:-|[ \\t]*$)", caseInsensitive: false, setsMatch: false) != nil { ok = back() }
325        guard ok else {
326            point = origin
327            throw UserError(message: "Cannot move to previous table field")
328        }
329        if let bar = looking("\\| ?", caseInsensitive: false, setsMatch: false) { point = bar[0]!.upperBound }
330    }
331
332    /// `org-table-next-row`.
333    func tableNextRow() throws {
334        try tableMaybeEvalFormula()
335        moveIntoField()
336        tableAlign()
337        let column = tableCurrentColumn()
338        forwardLine(1)
339        if !bolp { insert("\n") }
340        if !atTable || atTableHline {
341            forwardLine(-1)
342            try tableInsertRow(below: true)
343        }
344        tableGotoColumn(column)
345        skipBackwardNot([124, 10, 13])
346        if character(at: point) == 32 { point += 1 }
347    }
348
349    /// `org-table-move-row`.
350    func tableMoveRow(up: Bool) throws {
351        let column = currentColumn
352        let pos = point
353        let hline1 = atTableHline
354        let dline1 = tableCurrentDataLine()
355        let dline2 = dline1 + (up ? -1 : 1)
356        if up, lineStart(point) == 0 { throw UserError(message: "Cannot move row further") }
357        forwardLine(up ? -1 : 1)
358        if (!up && eobp) || !atTable {
359            point = pos
360            throw UserError(message: "Cannot move row further")
361        }
362        let hline2 = atTableHline
363        point = pos
364        let rowStart = lineStart(point)
365        let rowEnd = min(length, lineEnd(point) + 1)
366        let row = substring(rowStart, rowEnd)
367        deleteRegion(rowStart, rowEnd)
368        forwardLine(up ? -1 : 1)
369        if !bolp { insert("\n") }
370        insert(row)
371        if !bolp { insert("\n") }
372        forwardLine(-1)
373        moveToColumn(column)
374        if !hline1, !hline2 {
375            fixFormulas("@", replace: [String(dline1): String(dline2), String(dline2): String(dline1)])
376        }
377    }
378
379    /// `org-table-move-column`.
380    func tableMoveColumn(left: Bool) throws {
381        guard atTable else { throw UserError(message: "Not at a table") }
382        try findDataLine()
383        try checkInsideDataField()
384        let column = tableCurrentColumn()
385        let first = left ? column - 1 : column
386        let target = left ? column - 1 : column + 1
387        let begin = tableBegin()
388        let end = marker(tableEnd())
389        defer { release(end) }
390        if left, column == 1 { throw UserError(message: "Cannot move column further left") }
391        if !left, looking("[^|\\n]*\\|[^|\\n]*$", caseInsensitive: false, setsMatch: false) != nil { throw UserError(message: "Cannot move column further right") }
392        let line = marker(lineStart(point))
393        point = begin
394        while point < end.position {
395            if !atTableHline {
396                tableGotoColumn(first, onDelim: true)
397                if let match = looking("\\|([^|\\n]+)\\|([^|\\n]+)\\|", caseInsensitive: false, setsMatch: false) {
398                    let a = substring(match[1]!.lowerBound, match[1]!.upperBound)
399                    let b = substring(match[2]!.lowerBound, match[2]!.upperBound)
400                    replace(match[1]!.lowerBound..<match[2]!.upperBound, with: b + "|" + a)
401                }
402            }
403            if forwardLine(1) != 0 { break }
404        }
405        point = line.position
406        release(line)
407        tableGotoColumn(column)
408        tableGotoColumn(target)
409        tableAlign()
410        fixFormulas("$", replace: [String(column): String(target), String(target): String(column)])
411    }
412
413    /// `org-table-insert-column`.
414    func tableInsertColumn() throws {
415        guard atTable else { throw UserError(message: "Not at a table") }
416        if eobp { saveExcursion { insert("\n") } }
417        if substring(lineStart(point), lineEnd(point)).range(of: "\\|[ \\t]*$", options: .regularExpression) == nil { tableAlign() }
418        try findDataLine()
419        let column = max(1, tableCurrentColumn())
420        let begin = tableBegin()
421        let end = marker(tableEnd())
422        defer { release(end) }
423        saveExcursion {
424            point = begin
425            while point < end.position {
426                if !atTableHline {
427                    tableGotoColumn(column, onDelim: true)
428                    insert("|")
429                }
430                if forwardLine(1) != 0 { break }
431            }
432        }
433        tableGotoColumn(column)
434        tableAlign()
435        fixFormulas("$", replace: [:], limit: column - 1, delta: 1)
436    }
437
438    /// `org-table-delete-column`.
439    func tableDeleteColumn() throws {
440        guard atTable else { throw UserError(message: "Not at a table") }
441        try findDataLine()
442        if saveExcursion({ () -> Bool in skipForward(Self.blanks); return eolp }) {
443            if let match = searchBackward("\\|", caseInsensitive: false) { point = match[0]!.lowerBound }
444        }
445        try checkInsideDataField()
446        let column = tableCurrentColumn()
447        let begin = tableBegin()
448        let end = marker(tableEnd())
449        defer { release(end) }
450        let line = marker(lineStart(point))
451        point = begin
452        while point < end.position {
453            if !atTableHline {
454                tableGotoColumn(column, onDelim: true)
455                if let match = looking("\\|[^|\\n]+\\|", caseInsensitive: false, setsMatch: false) { replace(match[0]!, with: "|") }
456            }
457            if forwardLine(1) != 0 { break }
458        }
459        point = line.position
460        release(line)
461        tableGotoColumn(column)
462        tableAlign()
463        fixFormulas("$", replace: [String(column): "INVALID"], limit: column, delta: -1, remove: column)
464    }
465
466    /// `org-table-kill-row`.
467    func tableKillRow() throws {
468        guard atTable else { throw UserError(message: "Not at a table") }
469        let column = currentColumn
470        let dline = atTableHline ? nil : tableCurrentDataLine()
471        deleteRegion(lineStart(point), min(lineEnd(point) + 1, length))
472        if !atTable { forwardLine(-1) }
473        moveToColumn(column)
474        if let dline { fixFormulas("@", replace: [String(dline): "INVALID"], limit: dline, delta: -1, remove: dline) }
475    }
476
477    /// `org-table-find-dataline`.
478    func findDataLine() throws {
479        guard atTableHline else { return }
480        let column = currentColumn
481        let end = tableEnd()
482        forwardLine(1)
483        while point < end, atTableHline { forwardLine(1) }
484        if point >= end { throw UserError(message: "Cannot find data row for column operation") }
485        moveToColumn(column)
486    }
487
488    /// `org-table-check-inside-data-field`.
489    func checkInsideDataField() throws {
490        let atLineStart = saveExcursion { () -> Bool in skipBackward(Self.blanks); return bolp }
491        if atLineStart || atTableHline || looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil {
492            throw UserError(message: "Not in table data field")
493        }
494    }
495
496    /// `org-table-fix-formulas`.
497    func fixFormulas(_ key: String, replace replacements: [String: String], limit: Int? = nil, delta: Int = 0, remove: Int? = nil) {
498        saveExcursion {
499            point = tableEnd()
500            while looking("[ \\t]*#\\+tblfm:", setsMatch: false) != nil {
501                if let remove {
502                    let pattern = key == "$" ? "(@[0-9]+)?\\$\(remove)=.*?(::|$)" : "@\(remove)\\$[0-9]+=.*?(::|$)"
503                    saveExcursion {
504                        while let match = searchForward(pattern, bound: lineEnd(point), caseInsensitive: false) {
505                            replace(match[0]!, with: "")
506                            point = match[0]!.lowerBound
507                        }
508                    }
509                }
510                let lineStartOffset = point
511                let pattern = NSRegularExpression.escapedPattern(for: key) + "([0-9]+)"
512                while let match = searchForward(pattern, bound: lineEnd(point), caseInsensitive: false) {
513                    let number = substring(match[1]!.lowerBound, match[1]!.upperBound)
514                    if let new = replacements[number] {
515                        replace(match[0]!, with: key + new)
516                    } else if let limit, let n = Int(number), n > limit {
517                        replace(match[0]!, with: key + String(n + delta))
518                    }
519                }
520                _ = lineStartOffset
521                if forwardLine(1) != 0 { break }
522            }
523        }
524    }
525
526    /// `org-table-create` with a size like `3x2`.
527    func tableCreate(_ size: String) throws {
528        let parts = size.split(separator: "x").map { $0.trimmingCharacters(in: .whitespaces) }
529        guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]) else { throw UserError(message: "Size must be like 5x2") }
530        let pos = point
531        let indent = String(repeating: " ", count: currentColumn)
532        let line = indent + "|" + String(repeating: "  |", count: columns) + "\n"
533        if substring(lineStart(point), point).allSatisfy({ $0 == " " || $0 == "\t" }) {
534            beginningOfLine()
535        } else {
536            insert("\n")
537        }
538        for _ in 0..<rows { insert(line) }
539        point = pos
540        if rows > 1 {
541            endOfLine()
542            insert("\n|-")
543            point = pos
544        }
545        tableAlign()
546    }
547}
548
549
550private func tableCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
551    commitBuffer(context, body)
552}
553
554/// A table command that needs the caret in an org table.
555public struct TableCommand: OrgCommand {
556    public enum Action: String, Sendable, CaseIterable {
557        case align, nextField, previousField, nextRow, moveRowUp, moveRowDown, moveColumnLeft, moveColumnRight
558        case insertColumn, deleteColumn, killRow, insertRowAbove, insertHline
559    }
560
561    public let action: Action
562    public init(_ action: Action) { self.action = action }
563
564    public var id: String {
565        switch action {
566        case .align: "org.table.align"
567        case .nextField: "org.table.next-field"
568        case .previousField: "org.table.previous-field"
569        case .nextRow: "org.table.next-row"
570        case .moveRowUp: "org.table.row-up"
571        case .moveRowDown: "org.table.row-down"
572        case .moveColumnLeft: "org.table.column-left"
573        case .moveColumnRight: "org.table.column-right"
574        case .insertColumn: "org.table.insert-column"
575        case .deleteColumn: "org.table.delete-column"
576        case .killRow: "org.table.kill-row"
577        case .insertRowAbove: "org.table.insert-row"
578        case .insertHline: "org.table.insert-hline"
579        }
580    }
581
582    public var title: String {
583        switch action {
584        case .align: "Align Table"
585        case .nextField: "Next Table Field"
586        case .previousField: "Previous Table Field"
587        case .nextRow: "Next Table Row"
588        case .moveRowUp: "Move Table Row Up"
589        case .moveRowDown: "Move Table Row Down"
590        case .moveColumnLeft: "Move Table Column Left"
591        case .moveColumnRight: "Move Table Column Right"
592        case .insertColumn: "Insert Table Column"
593        case .deleteColumn: "Delete Table Column"
594        case .killRow: "Delete Table Row"
595        case .insertRowAbove: "Insert Table Row"
596        case .insertHline: "Insert Table Rule"
597        }
598    }
599
600    public func applies(in context: EditContext) -> Bool { KeyContext.holds("table", in: context) }
601
602    public func run(in context: EditContext) -> CommandStep {
603        tableCommand(context) { buffer in
604            guard buffer.atTable else { throw EmacsBuffer.UserError(message: "Not at a table") }
605            switch action {
606            case .align: buffer.tableAlign()
607            case .nextField: try buffer.tableNextField()
608            case .previousField: try buffer.tablePreviousField()
609            case .nextRow: try buffer.tableNextRow()
610            case .moveRowUp: try buffer.tableMoveRow(up: true)
611            case .moveRowDown: try buffer.tableMoveRow(up: false)
612            case .moveColumnLeft: try buffer.tableMoveColumn(left: true)
613            case .moveColumnRight: try buffer.tableMoveColumn(left: false)
614            case .insertColumn: try buffer.tableInsertColumn()
615            case .deleteColumn: try buffer.tableDeleteColumn()
616            case .killRow: try buffer.tableKillRow()
617            case .insertRowAbove: try buffer.tableInsertRow(below: false)
618            case .insertHline: try buffer.tableInsertHline(above: false)
619            }
620        }
621    }
622}
623
624/// `#+STARTUP: align` (`org-startup-align-all-tables`): every table aligned.
625public struct AlignAllTables: OrgCommand {
626    public init() {}
627    public var id: String { "org.table.align-all" }
628    public var title: String { "Align All Tables" }
629    public func applies(in context: EditContext) -> Bool { true }
630    public func run(in context: EditContext) -> CommandStep {
631        let starts = context.tree.root.descendants().filter { $0.kind == .table }.map(\.range.lowerBound)
632        guard !starts.isEmpty else { return .commit(EditResult(baseRevision: context.revision, edits: [])) }
633        return commitBuffer(context) { buffer in
634            let caret = buffer.marker(buffer.point)
635            defer {
636                buffer.point = caret.position
637                buffer.release(caret)
638            }
639            for start in starts.reversed() {
640                buffer.point = start
641                buffer.skipForward([32, 9])
642                buffer.point += 1
643                buffer.tableAlign()
644            }
645        }
646    }
647}
648
649/// C-c |: `org-table-create` with a size prompt.
650public struct CreateTable: OrgCommand {
651    public init() {}
652    public var id: String { "org.table.create" }
653    public var title: String { "Create Table" }
654    public func applies(in context: EditContext) -> Bool { !KeyContext.holds("table", in: context) }
655    public func run(in context: EditContext) -> CommandStep {
656        if let converted = convert(context) { return converted }
657        guard let size = context.answers["size"] else {
658            return .prompt(Prompt(key: "size", message: "Table size Columns x Rows [e.g. 5x2]:", initial: ""))
659        }
660        return tableCommand(context) { try $0.tableCreate(size.isEmpty ? "5x2" : size) }
661    }
662
663    /// `org-table-create-or-convert-from-region`.
664    private func convert(_ context: EditContext) -> CommandStep? {
665        guard let region = context.selection.first, !region.isEmpty else { return nil }
666        return commitBuffer(context) { $0.tableConvertRegion(region.lowerBound, region.upperBound) }
667    }
668}