Commit a49927e318
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Vim.swift +75 −8
| @@ -8,13 +8,14 @@ public struct Vim: Sendable { | |||
| 8 | public enum Mode: String, Sendable { | 8 | public enum Mode: String, Sendable { |
| 9 | case normal, insert, visual | 9 | case normal, insert, visual |
| 10 | case visualLine = "visual-line" | 10 | case visualLine = "visual-line" |
| 11 | case visualBlock = "visual-block" | ||
| 11 | 12 | ||
| 12 | /// The keymap mode name for bindings. | 13 | /// The keymap mode name for bindings. |
| 13 | public var keymapMode: String { | 14 | public var keymapMode: String { |
| 14 | switch self { | 15 | switch self { |
| 15 | case .normal: "normal" | 16 | case .normal: "normal" |
| 16 | case .insert: "insert" | 17 | case .insert: "insert" |
| 17 | case .visual, .visualLine: "visual" | 18 | case .visual, .visualLine, .visualBlock: "visual" |
| 18 | } | 19 | } |
| 19 | } | 20 | } |
| 20 | } | 21 | } |
| @@ -32,6 +33,19 @@ public struct Vim: Sendable { | |||
| 32 | /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). | 33 | /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). |
| 33 | case prompt(String) | 34 | case prompt(String) |
| 34 | case message(String) | 35 | case message(String) |
| 36 | /// `m`: a mark at this offset, for the editor to keep through edits. | ||
| 37 | case setMark(Character, Int) | ||
| 38 | /// A jump left this offset: the editor adds it to the jump list. | ||
| 39 | case jumped(from: Int) | ||
| 40 | /// C-o and C-i through the jump list. | ||
| 41 | case jumpBack(Int) | ||
| 42 | case jumpForward(Int) | ||
| 43 | /// `q`: start recording keys into a register, or with nil stop. | ||
| 44 | case recordMacro(Character?) | ||
| 45 | /// `@`: play a register's keys, this many times. | ||
| 46 | case playMacro(Character, Int) | ||
| 47 | /// A block selection: one range per line. | ||
| 48 | case selectBlock([Range<Int>]) | ||
| 35 | } | 49 | } |
| 36 | 50 | ||
| 37 | public struct Context: Sendable { | 51 | public struct Context: Sendable { |
| @@ -40,16 +54,19 @@ public struct Vim: Sendable { | |||
| 40 | public var clipboard: String? | 54 | public var clipboard: String? |
| 41 | /// Lines in a half page, for C-d and C-u. | 55 | /// Lines in a half page, for C-d and C-u. |
| 42 | public var halfPage: Int | 56 | public var halfPage: Int |
| 57 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. | ||
| 58 | public var marks: [Character: Int] | ||
| 43 | 59 | ||
| 44 | public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15) { | 60 | public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:]) { |
| 45 | self.text = text | 61 | self.text = text |
| 46 | self.caret = caret | 62 | self.caret = caret |
| 47 | self.clipboard = clipboard | 63 | self.clipboard = clipboard |
| 48 | self.halfPage = halfPage | 64 | self.halfPage = halfPage |
| 65 | self.marks = marks | ||
| 49 | } | 66 | } |
| 50 | } | 67 | } |
| 51 | 68 | ||
| 52 | public private(set) var mode: Mode = .normal | 69 | public internal(set) var mode: Mode = .normal |
| 53 | /// Keys of the command being typed. | 70 | /// Keys of the command being typed. |
| 54 | public private(set) var pending: [KeyChord] = [] | 71 | public private(set) var pending: [KeyChord] = [] |
| 55 | var registers: [Character: (text: String, linewise: Bool)] = [:] | 72 | var registers: [Character: (text: String, linewise: Bool)] = [:] |
| @@ -68,6 +85,20 @@ public struct Vim: Sendable { | |||
| 68 | var caretForApply = 0 | 85 | var caretForApply = 0 |
| 69 | /// Typed in insert state since it began, kept by `inserted(_:)`. | 86 | /// Typed in insert state since it began, kept by `inserted(_:)`. |
| 70 | var insertedText = "" | 87 | var insertedText = "" |
| 88 | /// The last visual selection, for `gv`. | ||
| 89 | var lastVisual: (anchor: Int, caret: Int, mode: Mode)? | ||
| 90 | /// Marks the engine set this run, for a context without the editor's. | ||
| 91 | var marks: [Character: Int] = [:] | ||
| 92 | /// Where block `I`, `A` and `c` copy what was typed when insert state ends: the other lines, | ||
| 93 | /// by number from 1, the column, and whether short lines are padded to it. | ||
| 94 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? | ||
| 95 | /// The editor's marks for this key. | ||
| 96 | var contextMarks: [Character: Int] = [:] | ||
| 97 | /// Keys are going into a macro register. | ||
| 98 | public internal(set) var recording = false | ||
| 99 | var lastMacro: Character? | ||
| 100 | /// `$` in block visual state: every line to its end. | ||
| 101 | var blockToEnd = false | ||
| 71 | 102 | ||
| 72 | public init() {} | 103 | public init() {} |
| 73 | 104 | ||
| @@ -77,10 +108,11 @@ public struct Vim: Sendable { | |||
| 77 | switch mode { | 108 | switch mode { |
| 78 | case .insert: | 109 | case .insert: |
| 79 | return insertKey(key, context) | 110 | return insertKey(key, context) |
| 80 | case .normal, .visual, .visualLine: | 111 | case .normal, .visual, .visualLine, .visualBlock: |
| 81 | pending.append(key) | 112 | pending.append(key) |
| 82 | let keys = pending | 113 | let keys = pending |
| 83 | caretForApply = context.caret | 114 | caretForApply = context.caret |
| 115 | contextMarks = context.marks | ||
| 84 | var buffer = Buffer(context) | 116 | var buffer = Buffer(context) |
| 85 | switch parse(keys, &buffer, context) { | 117 | switch parse(keys, &buffer, context) { |
| 86 | case .incomplete: | 118 | case .incomplete: |
| @@ -115,6 +147,10 @@ public struct Vim: Sendable { | |||
| 115 | } | 147 | } |
| 116 | let caret = context.caret | 148 | let caret = context.caret |
| 117 | let lineStart = buffer.lineStart(caret) | 149 | let lineStart = buffer.lineStart(caret) |
| 150 | if let block = blockInsert { | ||
| 151 | blockInsert = nil | ||
| 152 | if !insertedText.isEmpty, !insertedText.contains("\n") { return replicateBlockInsert(block, buffer, caret: caret) } | ||
| 153 | } | ||
| 118 | return [.move(caret: caret > lineStart ? caret - 1 : caret)] | 154 | return [.move(caret: caret > lineStart ? caret - 1 : caret)] |
| 119 | } | 155 | } |
| 120 | if key == KeyChord("w", .control) { | 156 | if key == KeyChord("w", .control) { |
| @@ -204,6 +240,9 @@ public struct Vim: Sendable { | |||
| 204 | let n = count ?? 1 | 240 | let n = count ?? 1 |
| 205 | let caret = context.caret | 241 | let caret = context.caret |
| 206 | 242 | ||
| 243 | if mode == .visualBlock { return parseBlock(rest, &buffer, context, register: register, count: n, keys: keys) } | ||
| 244 | if let result = parseExtras(rest, &buffer, context, register: register, count: count, keys: keys) { return result } | ||
| 245 | |||
| 207 | // Operators. | 246 | // Operators. |
| 208 | let operatorKeys: [String: String] = ["d": "d", "c": "c", "y": "y", ">": ">", "<": "<"] | 247 | let operatorKeys: [String: String] = ["d": "d", "c": "c", "y": "y", ">": ">", "<": "<"] |
| 209 | var op: String? | 248 | var op: String? |
| @@ -212,7 +251,7 @@ public struct Vim: Sendable { | |||
| 212 | op = o | 251 | op = o |
| 213 | } else if first.key == "g", first.modifiers.isEmpty { | 252 | } else if first.key == "g", first.modifiers.isEmpty { |
| 214 | guard let second = rest.dropFirst().first else { return .incomplete } | 253 | guard let second = rest.dropFirst().first else { return .incomplete } |
| 215 | if ["~", "u", "U"].contains(second.key), second.modifiers.isEmpty { | 254 | if ["~", "u", "U", "c"].contains(second.key), second.modifiers.isEmpty { |
| 216 | op = "g" + second.key | 255 | op = "g" + second.key |
| 217 | afterOp = rest.dropFirst(2) | 256 | afterOp = rest.dropFirst(2) |
| 218 | } | 257 | } |
| @@ -240,6 +279,8 @@ public struct Vim: Sendable { | |||
| 240 | if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty { | 279 | if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty { |
| 241 | guard motionKeys.count >= 2 else { return .incomplete } | 280 | guard motionKeys.count >= 2 else { return .incomplete } |
| 242 | guard let object = textObject(motionFirst.key == "i", motionKeys[motionKeys.startIndex + 1], buffer, caret, count: total) else { return .invalid } | 281 | guard let object = textObject(motionFirst.key == "i", motionKeys[motionKeys.startIndex + 1], buffer, caret, count: total) else { return .invalid } |
| 282 | // evil-org's subtree objects move point to the heading first. | ||
| 283 | if motionKeys[motionKeys.startIndex + 1].key == "R" { caretForApply = buffer.lineStart(object.range.lowerBound) } | ||
| 243 | return .done(apply(op, range: object.range, linewise: object.linewise, buffer, register: register, keys: keys, count: total)) | 284 | return .done(apply(op, range: object.range, linewise: object.linewise, buffer, register: register, keys: keys, count: total)) |
| 244 | } | 285 | } |
| 245 | var motionCaret = caret | 286 | var motionCaret = caret |
| @@ -285,6 +326,7 @@ public struct Vim: Sendable { | |||
| 285 | if visual, first.modifiers.isEmpty { | 326 | if visual, first.modifiers.isEmpty { |
| 286 | switch first.key { | 327 | switch first.key { |
| 287 | case "ESC": | 328 | case "ESC": |
| 329 | lastVisual = (anchor, caret, mode) | ||
| 288 | mode = .normal | 330 | mode = .normal |
| 289 | return .done([.move(caret: buffer.clampNormal(caret))]) | 331 | return .done([.move(caret: buffer.clampNormal(caret))]) |
| 290 | case "o": | 332 | case "o": |
| @@ -427,7 +469,8 @@ public struct Vim: Sendable { | |||
| 427 | case .invalid: return .invalid | 469 | case .invalid: return .invalid |
| 428 | case .moved(let to, _): | 470 | case .moved(let to, _): |
| 429 | if visual { return .done(visualActions(buffer, to)) } | 471 | if visual { return .done(visualActions(buffer, to)) } |
| 430 | return .done([.move(caret: buffer.clampNormal(to))]) | 472 | let jump: [Action] = Self.isJump(Array(rest)) && to != caret ? [.jumped(from: caret)] : [] |
| 473 | return .done(jump + [.move(caret: buffer.clampNormal(to))]) | ||
| 431 | } | 474 | } |
| 432 | } | 475 | } |
| 433 | 476 | ||
| @@ -560,6 +603,13 @@ public struct Vim: Sendable { | |||
| 560 | case ("%", []): | 603 | case ("%", []): |
| 561 | guard let p = buffer.matchingBracket(c) else { return .invalid } | 604 | guard let p = buffer.matchingBracket(c) else { return .invalid } |
| 562 | return .moved(p, .inclusive) | 605 | return .moved(p, .inclusive) |
| 606 | case ("'", []), ("`", []): | ||
| 607 | guard keys.count >= 2 else { return .incomplete } | ||
| 608 | let name = keys[1].key == "'" || keys[1].key == "`" ? Character("'") : keys[1].key.first | ||
| 609 | guard let name, let mark = currentMarks[name] else { return .invalid } | ||
| 610 | goalColumn = nil | ||
| 611 | let p = min(mark, buffer.length) | ||
| 612 | return key.key == "'" ? .moved(buffer.firstNonBlank(buffer.lineStart(p)), .linewise) : .moved(p, .exclusive) | ||
| 563 | case ("}", []), ("{", []): | 613 | case ("}", []), ("{", []): |
| 564 | var p = c | 614 | var p = c |
| 565 | for _ in 0..<n { p = buffer.paragraph(p, forward: key.key == "}") } | 615 | for _ in 0..<n { p = buffer.paragraph(p, forward: key.key == "}") } |
| @@ -642,6 +692,8 @@ public struct Vim: Sendable { | |||
| 642 | return buffer.bracketObject(c, open: 60, close: 62, inner: inner).map { ($0, false) } | 692 | return buffer.bracketObject(c, open: 60, close: 62, inner: inner).map { ($0, false) } |
| 643 | case "p": | 693 | case "p": |
| 644 | return buffer.paragraphObject(c, inner: inner).map { ($0, true) } | 694 | return buffer.paragraphObject(c, inner: inner).map { ($0, true) } |
| 695 | case "e", "E", "r", "R": | ||
| 696 | return Self.orgObject(key.key, inner: inner, buffer, c) | ||
| 645 | default: | 697 | default: |
| 646 | return nil | 698 | return nil |
| 647 | } | 699 | } |
| @@ -698,6 +750,7 @@ public struct Vim: Sendable { | |||
| 698 | 750 | ||
| 699 | mutating func apply(_ op: String, range: Range<Int>, linewise: Bool, _ buffer: Buffer, register: Character?, keys: [KeyChord], count: Int) -> [Action] { | 751 | mutating func apply(_ op: String, range: Range<Int>, linewise: Bool, _ buffer: Buffer, register: Character?, keys: [KeyChord], count: Int) -> [Action] { |
| 700 | let wasVisual = mode == .visual || mode == .visualLine | 752 | let wasVisual = mode == .visual || mode == .visualLine |
| 753 | if wasVisual { lastVisual = (anchor, caretForApply, mode) } | ||
| 701 | let context0Column = max(0, caretForApply - buffer.lineStart(caretForApply)) | 754 | let context0Column = max(0, caretForApply - buffer.lineStart(caretForApply)) |
| 702 | mode = .normal | 755 | mode = .normal |
| 703 | let text = buffer.substring(range) | 756 | let text = buffer.substring(range) |
| @@ -764,6 +817,9 @@ public struct Vim: Sendable { | |||
| 764 | } | 817 | } |
| 765 | lastChange = (wasVisual ? [] : keys, nil) | 818 | lastChange = (wasVisual ? [] : keys, nil) |
| 766 | return [.edit(edits, caret: start)] | 819 | return [.edit(edits, caret: start)] |
| 820 | case "gc": | ||
| 821 | lastChange = (wasVisual ? [] : keys, nil) | ||
| 822 | return [.edit(Self.toggleComments(buffer, range), caret: buffer.lineStart(range.lowerBound))] | ||
| 767 | default: | 823 | default: |
| 768 | let transformed: String | 824 | let transformed: String |
| 769 | switch op { | 825 | switch op { |
| @@ -882,13 +938,13 @@ public struct Vim: Sendable { | |||
| 882 | let buffer = Buffer(context) | 938 | let buffer = Buffer(context) |
| 883 | guard let p = buffer.search(pattern, from: context.caret, forward: forward, count: 1) else { return [.message("Search failed: \(pattern)")] } | 939 | guard let p = buffer.search(pattern, from: context.caret, forward: forward, count: 1) else { return [.message("Search failed: \(pattern)")] } |
| 884 | if mode == .visual || mode == .visualLine { return visualActions(buffer, p) } | 940 | if mode == .visual || mode == .visualLine { return visualActions(buffer, p) } |
| 885 | return [.move(caret: p)] | 941 | return [.jumped(from: context.caret), .move(caret: p)] |
| 886 | } | 942 | } |
| 887 | 943 | ||
| 888 | /// Leave a visual state without acting, as `ESC` does. | 944 | /// Leave a visual state without acting, as `ESC` does. |
| 889 | public mutating func reset() { | 945 | public mutating func reset() { |
| 890 | pending = [] | 946 | pending = [] |
| 891 | if mode == .visual || mode == .visualLine { mode = .normal } | 947 | if mode == .visual || mode == .visualLine || mode == .visualBlock { mode = .normal } |
| 892 | } | 948 | } |
| 893 | 949 | ||
| 894 | // MARK: - Text | 950 | // MARK: - Text |
| @@ -964,6 +1020,17 @@ public struct Vim: Sendable { | |||
| 964 | return line | 1020 | return line |
| 965 | } | 1021 | } |
| 966 | 1022 | ||
| 1023 | /// The number, from 1, of the line holding `i`. | ||
| 1024 | func lineNumber(_ i: Int) -> Int { | ||
| 1025 | var count = 1 | ||
| 1026 | var p = 0 | ||
| 1027 | while p < min(i, length) { | ||
| 1028 | if char(p) == 10 { count += 1 } | ||
| 1029 | p += 1 | ||
| 1030 | } | ||
| 1031 | return count | ||
| 1032 | } | ||
| 1033 | |||
| 967 | func lineCount(_ r: Range<Int>) -> Int { | 1034 | func lineCount(_ r: Range<Int>) -> Int { |
| 968 | substring(r).filter { $0 == "\n" }.count + (r.upperBound > 0 && char(r.upperBound - 1) == 10 ? 0 : 1) | 1035 | substring(r).filter { $0 == "\n" }.count + (r.upperBound > 0 && char(r.upperBound - 1) == 10 ? 0 : 1) |
| 969 | } | 1036 | } |
Sources/OrgCore/Keymap/VimExtras.swift added +540
| @@ -0,0 +1,540 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Doom's evil beyond the basics: marks and the jump list, macros, `gv`, block visual state, | ||
| 4 | // evil-surround (`ys`, `cs`, `ds`, visual `S`), commenting (`gc`) and evil-org's text objects | ||
| 5 | // (`e` object, `E` element, `r` greater element, `R` subtree). | ||
| 6 | |||
| 7 | extension Vim { | ||
| 8 | /// Marks from the editor, over the ones set in this engine. | ||
| 9 | var currentMarks: [Character: Int] { marks.merging(contextMarks) { $1 } } | ||
| 10 | |||
| 11 | /// Motions evil records in the jump list. | ||
| 12 | static func isJump(_ keys: [KeyChord]) -> Bool { | ||
| 13 | guard let key = keys.first, key.modifiers.isEmpty else { return false } | ||
| 14 | if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L"].contains(key.key) { return true } | ||
| 15 | return key.key == "g" && keys.count > 1 && keys[1].key == "g" | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Keys evil handles here before operators and motions, or nil. | ||
| 19 | mutating func parseExtras(_ rest: ArraySlice<KeyChord>, _ buffer: inout Buffer, _ context: Context, register: Character?, count: Int?, keys: [KeyChord]) -> Parse? { | ||
| 20 | guard let first = rest.first else { return .incomplete } | ||
| 21 | let visual = mode == .visual || mode == .visualLine | ||
| 22 | let n = count ?? 1 | ||
| 23 | let caret = context.caret | ||
| 24 | let second = rest.dropFirst().first | ||
| 25 | func needSecond() -> Parse? { second == nil ? .incomplete : nil } | ||
| 26 | |||
| 27 | if first.modifiers == [.control] { | ||
| 28 | switch first.key { | ||
| 29 | case "o" where !visual: return .done([.jumpBack(n)]) | ||
| 30 | case "i" where !visual: return .done([.jumpForward(n)]) | ||
| 31 | case "v": | ||
| 32 | if visual { lastVisual = (anchor, caret, mode) } | ||
| 33 | mode = .visualBlock | ||
| 34 | if !visual { anchor = caret } | ||
| 35 | return .done(blockActions(buffer, caret)) | ||
| 36 | default: return nil | ||
| 37 | } | ||
| 38 | } | ||
| 39 | guard first.modifiers.isEmpty else { return nil } | ||
| 40 | |||
| 41 | if visual, first.key == "S" { | ||
| 42 | if let wait = needSecond() { return wait } | ||
| 43 | let range = visualRange(buffer, caret) | ||
| 44 | lastVisual = (anchor, caret, mode) | ||
| 45 | mode = .normal | ||
| 46 | return .done(surround(range.range, with: second!, linewise: range.linewise, buffer, keys: [])) | ||
| 47 | } | ||
| 48 | guard !visual else { return nil } | ||
| 49 | |||
| 50 | switch first.key { | ||
| 51 | case "m": | ||
| 52 | if let wait = needSecond() { return wait } | ||
| 53 | guard let name = second!.key.first, second!.key.count == 1, name.isLetter else { return .invalid } | ||
| 54 | marks[name] = caret | ||
| 55 | return .done([.setMark(name, caret)]) | ||
| 56 | case "q": | ||
| 57 | if recording { | ||
| 58 | recording = false | ||
| 59 | return .done([.recordMacro(nil)]) | ||
| 60 | } | ||
| 61 | if let wait = needSecond() { return wait } | ||
| 62 | guard let name = second!.key.first, second!.key.count == 1, name.isLetter || name.isNumber else { return .invalid } | ||
| 63 | recording = true | ||
| 64 | return .done([.recordMacro(name)]) | ||
| 65 | case "@": | ||
| 66 | if let wait = needSecond() { return wait } | ||
| 67 | let name: Character | ||
| 68 | if second!.key == "@" { | ||
| 69 | guard let last = lastMacro else { return .done([.message("No previous macro")]) } | ||
| 70 | name = last | ||
| 71 | } else { | ||
| 72 | guard let c = second!.key.first, second!.key.count == 1 else { return .invalid } | ||
| 73 | name = c | ||
| 74 | } | ||
| 75 | lastMacro = name | ||
| 76 | return .done([.playMacro(name, n)]) | ||
| 77 | case "g": | ||
| 78 | if let wait = needSecond() { return wait } | ||
| 79 | guard second!.key == "v", second!.modifiers.isEmpty else { return nil } | ||
| 80 | guard let last = lastVisual else { return .done([]) } | ||
| 81 | mode = last.mode | ||
| 82 | anchor = min(last.anchor, buffer.length) | ||
| 83 | let to = min(last.caret, buffer.length) | ||
| 84 | return .done(mode == .visualBlock ? blockActions(buffer, to) : visualActions(buffer, to)) | ||
| 85 | case "y", "c", "d": | ||
| 86 | guard second?.key == "s", second?.modifiers.isEmpty == true else { return nil } | ||
| 87 | return surroundCommand(first.key, Array(rest.dropFirst(2)), &buffer, caret: caret, count: n, keys: keys) | ||
| 88 | default: | ||
| 89 | return nil | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // MARK: - Surround | ||
| 94 | |||
| 95 | /// The delimiters evil-surround puts around text for `key`: an opening bracket adds a space | ||
| 96 | /// inside, a closing one doesn't. | ||
| 97 | static func delimiters(_ key: KeyChord) -> (open: String, close: String)? { | ||
| 98 | let pairs: [String: (String, String)] = [ | ||
| 99 | "(": ("( ", " )"), ")": ("(", ")"), "b": ("(", ")"), "[": ("[ ", " ]"), "]": ("[", "]"), "r": ("[", "]"), | ||
| 100 | "{": ("{ ", " }"), "}": ("{", "}"), "B": ("{", "}"), "<": ("< ", " >"), ">": ("<", ">"), "a": ("<", ">"), "SPC": (" ", " "), | ||
| 101 | ] | ||
| 102 | if let pair = pairs[key.key] { return pair } | ||
| 103 | guard key.modifiers.isEmpty, key.key.count == 1 else { return nil } | ||
| 104 | return (key.key, key.key) | ||
| 105 | } | ||
| 106 | |||
| 107 | /// `ys{motion}{char}`, `yss{char}`, `cs{old}{new}` and `ds{char}`. | ||
| 108 | mutating func surroundCommand(_ op: String, _ after: [KeyChord], _ buffer: inout Buffer, caret: Int, count: Int, keys: [KeyChord]) -> Parse { | ||
| 109 | switch op { | ||
| 110 | case "y": | ||
| 111 | guard let (_, count2, motionKeys) = Self.prefix(after), let motionFirst = motionKeys.first else { return .incomplete } | ||
| 112 | let total = count * (count2 ?? 1) | ||
| 113 | if motionFirst.key == "s", motionFirst.modifiers.isEmpty { | ||
| 114 | guard motionKeys.count >= 2 else { return .incomplete } | ||
| 115 | let line = buffer.lineStart(caret) | ||
| 116 | let range = buffer.firstNonBlank(line)..<buffer.lineEnd(line) | ||
| 117 | return .done(surround(range, with: motionKeys[motionKeys.startIndex + 1], linewise: false, buffer, keys: keys)) | ||
| 118 | } | ||
| 119 | if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty { | ||
| 120 | guard motionKeys.count >= 3 else { return .incomplete } | ||
| 121 | guard let object = textObject(motionFirst.key == "i", motionKeys[motionKeys.startIndex + 1], buffer, caret, count: total) else { return .invalid } | ||
| 122 | return .done(surround(object.range, with: motionKeys[motionKeys.startIndex + 2], linewise: object.linewise, buffer, keys: keys)) | ||
| 123 | } | ||
| 124 | var target = caret | ||
| 125 | switch motion(Array(motionKeys), &buffer, &target, count: total, operatorPending: true) { | ||
| 126 | case .incomplete: return .incomplete | ||
| 127 | case .invalid: return .invalid | ||
| 128 | case .moved(let to, let kind): | ||
| 129 | // The surround character follows the motion's keys. | ||
| 130 | let used = Self.motionLength(Array(motionKeys)) | ||
| 131 | guard motionKeys.count > used else { return .incomplete } | ||
| 132 | let char = motionKeys[motionKeys.startIndex + used] | ||
| 133 | var range: Range<Int> | ||
| 134 | switch kind { | ||
| 135 | case .linewise: range = buffer.lineStart(min(caret, to))..<buffer.lineEnd(max(caret, to)) | ||
| 136 | case .inclusive: range = min(caret, to)..<min(buffer.length, max(caret, to) + 1) | ||
| 137 | case .exclusive: range = min(caret, to)..<max(caret, to) | ||
| 138 | } | ||
| 139 | // ysw leaves the space after the word outside. | ||
| 140 | while range.upperBound > range.lowerBound, let c = buffer.char(range.upperBound - 1), Buffer.isBlank(c) { range = range.lowerBound..<(range.upperBound - 1) } | ||
| 141 | return .done(surround(range, with: char, linewise: kind == .linewise, buffer, keys: keys)) | ||
| 142 | } | ||
| 143 | case "d", "c": | ||
| 144 | guard let target = after.first else { return .incomplete } | ||
| 145 | if op == "c", after.count < 2 { return .incomplete } | ||
| 146 | guard let found = surrounding(target, buffer, caret) else { return .invalid } | ||
| 147 | var edits: [TextEdit] | ||
| 148 | if op == "d" { | ||
| 149 | edits = [TextEdit(range: found.close, replacement: ""), TextEdit(range: found.open, replacement: "")] | ||
| 150 | } else { | ||
| 151 | guard let (open, close) = Self.delimiters(after[after.startIndex + 1]) else { return .invalid } | ||
| 152 | edits = [TextEdit(range: found.close, replacement: close), TextEdit(range: found.open, replacement: open)] | ||
| 153 | } | ||
| 154 | lastChange = (keys, nil) | ||
| 155 | return .done([.edit(edits, caret: found.open.lowerBound)]) | ||
| 156 | default: | ||
| 157 | return .invalid | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | /// How many keys a motion takes: two for `f`, `t`, `'`, `` ` `` and `g`, otherwise one. | ||
| 162 | static func motionLength(_ keys: [KeyChord]) -> Int { | ||
| 163 | guard let first = keys.first, first.modifiers.isEmpty else { return 1 } | ||
| 164 | return ["f", "F", "t", "T", "'", "`", "g"].contains(first.key) ? 2 : 1 | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Wraps `range` in the delimiters for `key`; linewise, on lines of their own. | ||
| 168 | mutating func surround(_ range: Range<Int>, with key: KeyChord, linewise: Bool, _ buffer: Buffer, keys: [KeyChord]) -> [Action] { | ||
| 169 | guard let (open, close) = Self.delimiters(key) else { return [] } | ||
| 170 | var edits: [TextEdit] | ||
| 171 | if linewise { | ||
| 172 | let start = buffer.lineStart(range.lowerBound) | ||
| 173 | let end = buffer.lineEnd(max(range.lowerBound, range.upperBound - 1)) | ||
| 174 | edits = [TextEdit(range: end..<end, replacement: "\n" + close.trimmingCharacters(in: .whitespaces)), | ||
| 175 | TextEdit(range: start..<start, replacement: open.trimmingCharacters(in: .whitespaces) + "\n")] | ||
| 176 | } else { | ||
| 177 | edits = [TextEdit(range: range.upperBound..<range.upperBound, replacement: close), TextEdit(range: range.lowerBound..<range.lowerBound, replacement: open)] | ||
| 178 | } | ||
| 179 | if !keys.isEmpty { lastChange = (keys, nil) } | ||
| 180 | return [.edit(edits, caret: range.lowerBound)] | ||
| 181 | } | ||
| 182 | |||
| 183 | /// The delimiters around `caret` for `key`, as `ds` and `cs` find them; with an opening | ||
| 184 | /// bracket, the blanks inside go too. | ||
| 185 | func surrounding(_ key: KeyChord, _ buffer: Buffer, _ caret: Int) -> (open: Range<Int>, close: Range<Int>)? { | ||
| 186 | let pairs: [String: (unichar, unichar)] = ["(": (40, 41), ")": (40, 41), "b": (40, 41), "[": (91, 93), "]": (91, 93), "r": (91, 93), | ||
| 187 | "{": (123, 125), "}": (123, 125), "B": (123, 125), "<": (60, 62), ">": (60, 62), "a": (60, 62)] | ||
| 188 | let outer: Range<Int> | ||
| 189 | if let (open, close) = pairs[key.key] { | ||
| 190 | guard let r = buffer.bracketObject(caret, open: open, close: close, inner: false) else { return nil } | ||
| 191 | outer = r | ||
| 192 | } else if key.key.count == 1, let quote = key.key.utf16.first { | ||
| 193 | guard let r = buffer.quoteObject(caret, quote: quote, inner: false) else { return nil } | ||
| 194 | outer = r | ||
| 195 | // `a"` takes blanks after the closing quote; the delimiter is the quote itself. | ||
| 196 | var end = r.upperBound | ||
| 197 | while end > r.lowerBound, buffer.char(end - 1) != quote { end -= 1 } | ||
| 198 | return (r.lowerBound..<(r.lowerBound + 1), (end - 1)..<end) | ||
| 199 | } else { | ||
| 200 | return nil | ||
| 201 | } | ||
| 202 | var open = outer.lowerBound..<(outer.lowerBound + 1) | ||
| 203 | var close = (outer.upperBound - 1)..<outer.upperBound | ||
| 204 | if ["(", "[", "{", "<"].contains(key.key) { | ||
| 205 | while open.upperBound < close.lowerBound, let c = buffer.char(open.upperBound), Buffer.isBlank(c) { open = open.lowerBound..<(open.upperBound + 1) } | ||
| 206 | while close.lowerBound > open.upperBound, let c = buffer.char(close.lowerBound - 1), Buffer.isBlank(c) { close = (close.lowerBound - 1)..<close.upperBound } | ||
| 207 | } | ||
| 208 | return (open, close) | ||
| 209 | } | ||
| 210 | |||
| 211 | // MARK: - Commenting | ||
| 212 | |||
| 213 | /// `comment-or-uncomment-region` in Org: `# ` at the shallowest indentation of the lines' | ||
| 214 | /// text, blank lines left alone; when every line is a comment, the comments come off. | ||
| 215 | static func toggleComments(_ buffer: Buffer, _ range: Range<Int>) -> [TextEdit] { | ||
| 216 | var lines: [Int] = [] | ||
| 217 | var line = buffer.lineStart(range.lowerBound) | ||
| 218 | let last = buffer.lineStart(max(range.lowerBound, range.upperBound - 1)) | ||
| 219 | while true { | ||
| 220 | lines.append(line) | ||
| 221 | let end = buffer.lineEnd(line) | ||
| 222 | guard line < last, end < buffer.length else { break } | ||
| 223 | line = end + 1 | ||
| 224 | } | ||
| 225 | let content = lines.filter { buffer.firstNonBlankOrEnd($0) < buffer.lineEnd($0) } | ||
| 226 | guard !content.isEmpty else { return [] } | ||
| 227 | let commented = content.allSatisfy { start in | ||
| 228 | let text = buffer.substring(buffer.firstNonBlankOrEnd(start)..<buffer.lineEnd(start)) | ||
| 229 | return text == "#" || text.hasPrefix("# ") | ||
| 230 | } | ||
| 231 | if commented { | ||
| 232 | return content.reversed().map { start in | ||
| 233 | let at = buffer.firstNonBlankOrEnd(start) | ||
| 234 | let length = buffer.lineEnd(start) - at >= 2 && buffer.char(at + 1) == 32 ? 2 : 1 | ||
| 235 | return TextEdit(range: at..<(at + length), replacement: "") | ||
| 236 | } | ||
| 237 | } | ||
| 238 | let column = content.map { buffer.firstNonBlankOrEnd($0) - $0 }.min() ?? 0 | ||
| 239 | return content.reversed().map { TextEdit(range: ($0 + column)..<($0 + column), replacement: "# ") } | ||
| 240 | } | ||
| 241 | |||
| 242 | // MARK: - Org text objects | ||
| 243 | |||
| 244 | /// evil-org's text objects: `e` the object at point, `E` the element, `r` the greater element | ||
| 245 | /// around it, `R` the subtree. `a` takes the blanks after; `i` the contents. | ||
| 246 | /// An org-element: `end` takes the blanks after it, `contents` is nil for elements without any. | ||
| 247 | struct OrgElement { | ||
| 248 | var begin: Int | ||
| 249 | var end: Int | ||
| 250 | var contents: Range<Int>? | ||
| 251 | var greater: Bool | ||
| 252 | var block = false | ||
| 253 | var node: SyntaxNode? | ||
| 254 | } | ||
| 255 | |||
| 256 | static func orgObject(_ key: String, inner: Bool, _ buffer: Buffer, _ c: Int) -> (range: Range<Int>, linewise: Bool)? { | ||
| 257 | let tree = OrgParser.parse(buffer.ns as String) | ||
| 258 | var chain: [SyntaxNode] = [] | ||
| 259 | var node = tree.root | ||
| 260 | while let child = node.child(containing: c) { | ||
| 261 | chain.append(child) | ||
| 262 | node = child | ||
| 263 | } | ||
| 264 | /// The end of the line ending at or after `p`, without trailing blank lines. | ||
| 265 | func trimmed(_ lower: Int, _ upper: Int) -> Int { | ||
| 266 | var end = upper | ||
| 267 | while end > lower, let ch = buffer.char(end - 1), ch == 10 || Buffer.isBlank(ch) { end -= 1 } | ||
| 268 | if end > lower, buffer.char(end) == 10 { end += 1 } | ||
| 269 | return max(lower, end) | ||
| 270 | } | ||
| 271 | /// The org-element for a node of ours; a `section` here is Org's headline. | ||
| 272 | func element(_ n: SyntaxNode) -> OrgElement { | ||
| 273 | let r = n.range | ||
| 274 | var post = r.upperBound | ||
| 275 | let limit = n.parent?.range.upperBound ?? buffer.length | ||
| 276 | while post < limit { | ||
| 277 | let lineEnd = buffer.lineEnd(post) | ||
| 278 | guard (post..<lineEnd).allSatisfy({ buffer.char($0).map(Buffer.isBlank) ?? true }), lineEnd < buffer.length else { break } | ||
| 279 | post = min(limit, lineEnd + 1) | ||
| 280 | } | ||
| 281 | switch n.kind { | ||
| 282 | case .section: | ||
| 283 | let body = min(r.upperBound, buffer.lineEnd(r.lowerBound) + 1) | ||
| 284 | let contentsEnd = trimmed(body, r.upperBound) | ||
| 285 | return OrgElement(begin: r.lowerBound, end: r.upperBound, contents: body < contentsEnd ? body..<contentsEnd : nil, greater: true, node: n) | ||
| 286 | case .plainList, .table, .drawer, .propertyDrawer, .dynamicBlock, .footnoteDefinition, .inlineTask: | ||
| 287 | return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: true, node: n) | ||
| 288 | case .item: | ||
| 289 | let first = n.children.first { ![.bullet, .checkbox].contains($0.kind) }?.range.lowerBound ?? r.upperBound | ||
| 290 | return OrgElement(begin: r.lowerBound, end: post, contents: min(first, r.upperBound)..<trimmed(first, r.upperBound), greater: true, node: n) | ||
| 291 | case .block: | ||
| 292 | let name = buffer.substring(r.lowerBound..<buffer.lineEnd(r.lowerBound)).lowercased() | ||
| 293 | let greater = !["src", "example", "export", "verse", "comment"].contains { name.hasPrefix("#+begin_\($0)") } | ||
| 294 | return OrgElement(begin: r.lowerBound, end: post, contents: nil, greater: greater, block: true, node: n) | ||
| 295 | case .latexEnvironment: | ||
| 296 | return OrgElement(begin: r.lowerBound, end: post, contents: nil, greater: false, block: true, node: n) | ||
| 297 | case .paragraph: | ||
| 298 | return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: false, node: n) | ||
| 299 | default: | ||
| 300 | return OrgElement(begin: r.lowerBound, end: post, contents: nil, greater: false, node: n) | ||
| 301 | } | ||
| 302 | } | ||
| 303 | /// Org's section: the text under a heading before its first child, or before the first heading. | ||
| 304 | func section(_ n: SyntaxNode) -> OrgElement? { | ||
| 305 | let begin = n.kind == .zerothSection ? n.range.lowerBound : min(n.range.upperBound, buffer.lineEnd(n.range.lowerBound) + 1) | ||
| 306 | let end = n.children.first { $0.kind == .section && $0.range.lowerBound >= begin }?.range.lowerBound ?? n.range.upperBound | ||
| 307 | guard begin < end else { return nil } | ||
| 308 | return OrgElement(begin: begin, end: end, contents: begin..<trimmed(begin, end), greater: true) | ||
| 309 | } | ||
| 310 | func parent(_ e: OrgElement) -> OrgElement? { | ||
| 311 | guard let n = e.node else { | ||
| 312 | // A section's parent is its headline. | ||
| 313 | return chain.last { $0.kind == .section && $0.range.lowerBound < e.begin }.map(element) | ||
| 314 | } | ||
| 315 | guard let up = n.parent else { return nil } | ||
| 316 | switch up.kind { | ||
| 317 | case .section where n.kind != .section: return section(up) | ||
| 318 | case .zerothSection: return section(up) | ||
| 319 | case .document: return nil | ||
| 320 | default: return element(up) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | /// org-element-at-point. | ||
| 324 | func atPoint() -> OrgElement? { | ||
| 325 | let objects: Set<SyntaxKind> = [.title, .heading, .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, | ||
| 326 | .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, | ||
| 327 | .latexFragment, .lineBreak, .superscript, .subscript, .entity, .tableCell, .nodeProperty] | ||
| 328 | var elements = chain.filter { !objects.contains($0.kind) } | ||
| 329 | if chain.contains(where: { $0.kind == .heading }), let i = elements.lastIndex(where: { $0.kind == .section }) { | ||
| 330 | elements = Array(elements[...i]) | ||
| 331 | } | ||
| 332 | guard var n = elements.last else { return nil } | ||
| 333 | if [.section, .zerothSection, .plainList, .item].contains(n.kind), !chain.contains(where: { $0.kind == .heading }) { | ||
| 334 | // On a blank line: the element before it, whose post-blank it is. | ||
| 335 | if let before = n.children.last(where: { $0.range.upperBound <= c && $0.kind != .heading && $0.kind != .bullet }), !n.children.contains(where: { $0.range.contains(c) }) { | ||
| 336 | n = before | ||
| 337 | } else if n.kind == .zerothSection || n.kind == .section { | ||
| 338 | return section(n) | ||
| 339 | } | ||
| 340 | } | ||
| 341 | if let up = n.parent, up.range.lowerBound == n.range.lowerBound, (n.kind == .item && up.kind == .plainList) || (n.kind == .tableRow && up.kind == .table) { | ||
| 342 | n = up | ||
| 343 | } | ||
| 344 | return element(n) | ||
| 345 | } | ||
| 346 | func innerRange(_ e: OrgElement) -> Range<Int> { | ||
| 347 | if e.block { | ||
| 348 | let first = min(e.end, buffer.lineEnd(e.begin) + 1) | ||
| 349 | let closing = buffer.lineStart(max(e.begin, trimmed(e.begin, e.end) - 1)) | ||
| 350 | return min(first, closing)..<closing | ||
| 351 | } | ||
| 352 | if let contents = e.contents { return contents } | ||
| 353 | return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0)) | ||
| 354 | } | ||
| 355 | switch key { | ||
| 356 | case "R": | ||
| 357 | guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil } | ||
| 358 | return (inner ? (headline.contents ?? headline.begin..<headline.begin) : headline.begin..<headline.end, true) | ||
| 359 | case "E": | ||
| 360 | guard let e = atPoint() else { return nil } | ||
| 361 | return (inner ? innerRange(e) : e.begin..<e.end, false) | ||
| 362 | case "r": | ||
| 363 | guard var e = atPoint() else { return nil } | ||
| 364 | if !e.greater, let up = parent(e) { e = up } | ||
| 365 | return (inner ? innerRange(e) : e.begin..<e.end, !inner) | ||
| 366 | default: | ||
| 367 | let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .timestamp, .footnoteReference, | ||
| 368 | .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, .latexFragment, .entity, .superscript, .subscript] | ||
| 369 | guard let found = chain.last(where: { objects.contains($0.kind) }) else { | ||
| 370 | guard let e = atPoint() else { return nil } | ||
| 371 | return (inner ? innerRange(e) : e.begin..<e.end, false) | ||
| 372 | } | ||
| 373 | var range = found.range | ||
| 374 | var end = range.upperBound | ||
| 375 | while let ch = buffer.char(end), Buffer.isBlank(ch) { end += 1 } | ||
| 376 | if inner { | ||
| 377 | switch found.kind { | ||
| 378 | case .bold, .italic, .underline, .strikeThrough, .verbatim, .code: | ||
| 379 | range = (range.lowerBound + 1)..<(range.upperBound - 1) | ||
| 380 | case .link: | ||
| 381 | if let description = found.firstChild(.linkDescription) { range = description.range } | ||
| 382 | default: | ||
| 383 | break | ||
| 384 | } | ||
| 385 | return (range, false) | ||
| 386 | } | ||
| 387 | return (range.lowerBound..<end, false) | ||
| 388 | } | ||
| 389 | } | ||
| 390 | |||
| 391 | // MARK: - Block visual | ||
| 392 | |||
| 393 | /// The block's columns and line starts, from `anchor` to `caret`; `$` stretches it to each | ||
| 394 | /// line's end. | ||
| 395 | func block(_ buffer: Buffer, _ caret: Int) -> (lines: [Int], left: Int, right: Int) { | ||
| 396 | let a = min(anchor, caret), b = max(anchor, caret) | ||
| 397 | let left = min(anchor - buffer.lineStart(anchor), caret - buffer.lineStart(caret)) | ||
| 398 | let right = blockToEnd ? Int.max : max(anchor - buffer.lineStart(anchor), caret - buffer.lineStart(caret)) | ||
| 399 | var lines: [Int] = [] | ||
| 400 | var line = buffer.lineStart(a) | ||
| 401 | while true { | ||
| 402 | lines.append(line) | ||
| 403 | let end = buffer.lineEnd(line) | ||
| 404 | guard end < buffer.length, line < buffer.lineStart(b) else { break } | ||
| 405 | line = end + 1 | ||
| 406 | } | ||
| 407 | return (lines, left, right) | ||
| 408 | } | ||
| 409 | |||
| 410 | /// Each line's part of the block. | ||
| 411 | func blockRanges(_ buffer: Buffer, _ caret: Int) -> [Range<Int>] { | ||
| 412 | let (lines, left, right) = block(buffer, caret) | ||
| 413 | return lines.map { line in | ||
| 414 | let end = buffer.lineEnd(line) | ||
| 415 | let lower = min(line + left, end) | ||
| 416 | let upper = right == Int.max ? end : min(line + right + 1, end) | ||
| 417 | return lower..<max(lower, upper) | ||
| 418 | } | ||
| 419 | } | ||
| 420 | |||
| 421 | func blockActions(_ buffer: Buffer, _ caret: Int) -> [Action] { | ||
| 422 | [.move(caret: caret), .selectBlock(blockRanges(buffer, caret))] | ||
| 423 | } | ||
| 424 | |||
| 425 | /// Keys in block visual state. | ||
| 426 | mutating func parseBlock(_ rest: ArraySlice<KeyChord>, _ buffer: inout Buffer, _ context: Context, register: Character?, count n: Int, keys: [KeyChord]) -> Parse { | ||
| 427 | guard let first = rest.first else { return .incomplete } | ||
| 428 | let caret = context.caret | ||
| 429 | let ranges = blockRanges(buffer, caret) | ||
| 430 | func leave() { | ||
| 431 | lastVisual = (anchor, caret, .visualBlock) | ||
| 432 | mode = .normal | ||
| 433 | blockToEnd = false | ||
| 434 | } | ||
| 435 | if first == KeyChord("v", .control) || (first.modifiers.isEmpty && first.key == "ESC") { | ||
| 436 | leave() | ||
| 437 | return .done([.move(caret: buffer.clampNormal(caret))]) | ||
| 438 | } | ||
| 439 | guard first.modifiers.isEmpty else { return .invalid } | ||
| 440 | switch first.key { | ||
| 441 | case "v", "V": | ||
| 442 | lastVisual = (anchor, caret, .visualBlock) | ||
| 443 | mode = first.key == "v" ? .visual : .visualLine | ||
| 444 | blockToEnd = false | ||
| 445 | return .done(visualActions(buffer, caret)) | ||
| 446 | case "o", "O": | ||
| 447 | let old = anchor | ||
| 448 | anchor = caret | ||
| 449 | return .done(blockActions(buffer, old)) | ||
| 450 | case "$": | ||
| 451 | blockToEnd = true | ||
| 452 | return .done(blockActions(buffer, buffer.lineEnd(caret) > buffer.lineStart(caret) ? buffer.lineEnd(caret) - 1 : caret)) | ||
| 453 | case "d", "x", "y", "c": | ||
| 454 | let removed = ranges.map { buffer.substring($0) }.joined(separator: "\n") | ||
| 455 | leave() | ||
| 456 | var actions = store(removed, linewise: false, register: register, yank: first.key == "y") | ||
| 457 | let top = ranges.first?.lowerBound ?? caret | ||
| 458 | if first.key == "y" { return .done(actions + [.move(caret: top)]) } | ||
| 459 | let edits = ranges.reversed().map { TextEdit(range: $0, replacement: "") } | ||
| 460 | if first.key == "c" { | ||
| 461 | let (lines, left, _) = block(buffer, caret) | ||
| 462 | blockInsert = (lines.dropFirst().map { buffer.lineNumber($0) }, left, false) | ||
| 463 | actions.insert(.edit(edits, caret: top), at: 0) | ||
| 464 | enterInsert(at: top, keys: []) | ||
| 465 | return .done(actions) | ||
| 466 | } | ||
| 467 | actions.insert(.edit(edits, caret: buffer.removing(top..<top).clampNormal(top)), at: 0) | ||
| 468 | return .done(actions) | ||
| 469 | case "I", "A": | ||
| 470 | let (lines, left, right) = block(buffer, caret) | ||
| 471 | leave() | ||
| 472 | let column = first.key == "I" ? left : (right == Int.max ? Int.max : right + 1) | ||
| 473 | let top = lines.first ?? caret | ||
| 474 | let at: Int | ||
| 475 | if column == Int.max { | ||
| 476 | at = buffer.lineEnd(top) | ||
| 477 | } else { | ||
| 478 | at = min(top + column, buffer.lineEnd(top)) | ||
| 479 | } | ||
| 480 | blockInsert = (lines.dropFirst().map { buffer.lineNumber($0) }, column, first.key == "A") | ||
| 481 | var actions: [Action] = [] | ||
| 482 | // `A` past a short first line pads it with spaces. | ||
| 483 | if first.key == "A", column != Int.max, buffer.lineEnd(top) < top + column { | ||
| 484 | let pad = top + column - buffer.lineEnd(top) | ||
| 485 | actions.append(.edit([TextEdit(range: buffer.lineEnd(top)..<buffer.lineEnd(top), replacement: String(repeating: " ", count: pad))], caret: top + column)) | ||
| 486 | enterInsert(at: top + column, keys: []) | ||
| 487 | return .done(actions) | ||
| 488 | } | ||
| 489 | enterInsert(at: at, keys: []) | ||
| 490 | return .done([.move(caret: at)]) | ||
| 491 | case "r": | ||
| 492 | guard rest.count >= 2 else { return .incomplete } | ||
| 493 | let char = rest[rest.startIndex + 1].key == "SPC" ? " " : rest[rest.startIndex + 1].key | ||
| 494 | guard char.count == 1 else { return .invalid } | ||
| 495 | leave() | ||
| 496 | let edits = ranges.reversed().map { TextEdit(range: $0, replacement: String(repeating: char, count: $0.count)) } | ||
| 497 | return .done([.edit(edits, caret: ranges.first?.lowerBound ?? caret)]) | ||
| 498 | case "~", "u", "U": | ||
| 499 | leave() | ||
| 500 | let edits = ranges.reversed().map { r -> TextEdit in | ||
| 501 | let s = buffer.substring(r) | ||
| 502 | let t = first.key == "u" ? s.lowercased() : first.key == "U" ? s.uppercased() : String(s.map { $0.isUppercase ? Character($0.lowercased()) : Character($0.uppercased()) }) | ||
| 503 | return TextEdit(range: r, replacement: t) | ||
| 504 | } | ||
| 505 | return .done([.edit(edits, caret: ranges.first?.lowerBound ?? caret)]) | ||
| 506 | case ">", "<": | ||
| 507 | leave() | ||
| 508 | let lines = block(buffer, caret).lines | ||
| 509 | let range = (lines.first ?? caret)..<min(buffer.length, buffer.lineEnd(lines.last ?? caret) + 1) | ||
| 510 | mode = .normal | ||
| 511 | return .done(apply(first.key, range: range, linewise: true, buffer, register: register, keys: [], count: n)) | ||
| 512 | default: | ||
| 513 | var target = caret | ||
| 514 | switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) { | ||
| 515 | case .incomplete: return .incomplete | ||
| 516 | case .invalid: return .invalid | ||
| 517 | case .moved(let to, _): return .done(blockActions(buffer, to)) | ||
| 518 | } | ||
| 519 | } | ||
| 520 | } | ||
| 521 | |||
| 522 | /// What block `I`, `A` or `c` typed, put on the block's other lines at its column. | ||
| 523 | func replicateBlockInsert(_ block: (lines: [Int], column: Int, pad: Bool), _ buffer: Buffer, caret: Int) -> [Action] { | ||
| 524 | var edits: [TextEdit] = [] | ||
| 525 | for number in block.lines.reversed() { | ||
| 526 | let line = buffer.lineOffset(number) | ||
| 527 | let end = buffer.lineEnd(line) | ||
| 528 | if block.column == Int.max { | ||
| 529 | edits.append(TextEdit(range: end..<end, replacement: insertedText)) | ||
| 530 | } else if line + block.column <= end { | ||
| 531 | edits.append(TextEdit(range: (line + block.column)..<(line + block.column), replacement: insertedText)) | ||
| 532 | } else if block.pad { | ||
| 533 | edits.append(TextEdit(range: end..<end, replacement: String(repeating: " ", count: line + block.column - end) + insertedText)) | ||
| 534 | } | ||
| 535 | } | ||
| 536 | let lineStart = buffer.lineStart(caret) | ||
| 537 | let back = caret > lineStart ? caret - 1 : caret | ||
| 538 | return edits.isEmpty ? [.move(caret: back)] : [.edit(edits, caret: back)] | ||
| 539 | } | ||
| 540 | } | ||
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -87,6 +87,7 @@ public enum EditorCommands { | |||
| 87 | EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), | 87 | EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), |
| 88 | EditorCommand(id: "app.export-dialog", title: "Export…", action: .app), | 88 | EditorCommand(id: "app.export-dialog", title: "Export…", action: .app), |
| 89 | EditorCommand(id: "app.sync-conflicts", title: "Resolve Sync Conflicts…", action: .app), | 89 | EditorCommand(id: "app.sync-conflicts", title: "Resolve Sync Conflicts…", action: .app), |
| 90 | EditorCommand(id: "app.revert", title: "Revert to File on Disk", action: .app), | ||
| 90 | EditorCommand(id: "app.recovery", title: "Recovery Versions…", action: .app), | 91 | EditorCommand(id: "app.recovery", title: "Recovery Versions…", action: .app), |
| 91 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), | 92 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 92 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), | 93 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
Sources/OrgEditorAppKit/Modal.swift +74 −3
| @@ -1,6 +1,7 @@ | |||
| 1 | #if os(macOS) | 1 | #if os(macOS) |
| 2 | import AppKit | 2 | import AppKit |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgDocument | ||
| 4 | 5 | ||
| 5 | /// Modal editing: keys go to the keymap for the current evil state first (leader keys, | 6 | /// Modal editing: keys go to the keymap for the current evil state first (leader keys, |
| 6 | /// evil-org's org keys), then to `Vim`. | 7 | /// evil-org's org keys), then to `Vim`. |
| @@ -8,6 +9,7 @@ extension OrgEditor { | |||
| 8 | func handleModal(_ chord: KeyChord) -> Bool { | 9 | func handleModal(_ chord: KeyChord) -> Bool { |
| 9 | // ⌘ shortcuts stay with the menus. | 10 | // ⌘ shortcuts stay with the menus. |
| 10 | if chord.modifiers.contains(.command) { return false } | 11 | if chord.modifiers.contains(.command) { return false } |
| 12 | if recordingRegister != nil, replayingMacro == 0 { recordedKeys.append(chord) } | ||
| 11 | dispatcher.mode = vim.mode.keymapMode | 13 | dispatcher.mode = vim.mode.keymapMode |
| 12 | if !vim.pending.isEmpty, vim.mode != .insert { | 14 | if !vim.pending.isEmpty, vim.mode != .insert { |
| 13 | return feedVim([chord]) | 15 | return feedVim([chord]) |
| @@ -61,7 +63,9 @@ extension OrgEditor { | |||
| 61 | let visual = vim.mode == .visual || vim.mode == .visualLine | 63 | let visual = vim.mode == .visual || vim.mode == .visualLine |
| 62 | let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location | 64 | let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location |
| 63 | let clipboard = NSPasteboard.general.string(forType: .string) | 65 | let clipboard = NSPasteboard.general.string(forType: .string) |
| 64 | let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage)) | 66 | var marks = vimMarks |
| 67 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } | ||
| 68 | let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks)) | ||
| 65 | apply(actions) | 69 | apply(actions) |
| 66 | if !wasInsert, vim.mode == .insert { | 70 | if !wasInsert, vim.mode == .insert { |
| 67 | insertSnapshot = (document.text, textView.selectedRange().location) | 71 | insertSnapshot = (document.text, textView.selectedRange().location) |
| @@ -74,7 +78,16 @@ extension OrgEditor { | |||
| 74 | return true | 78 | return true |
| 75 | } | 79 | } |
| 76 | 80 | ||
| 77 | private func insertTyped(_ key: KeyChord) { | 81 | /// Moves marks and jump positions through an edit. |
| 82 | func mapVimPositions(through edit: TextEdit) { | ||
| 83 | guard !vimMarks.isEmpty || !jumpList.isEmpty else { return } | ||
| 84 | let names = Array(vimMarks.keys) | ||
| 85 | let mapped = ViewState(selection: names.map { vimMarks[$0]!..<vimMarks[$0]! } + jumpList.map { $0..<$0 }).mapped(through: [edit]).selection | ||
| 86 | for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound } | ||
| 87 | jumpList = mapped.dropFirst(names.count).map { $0.lowerBound } | ||
| 88 | } | ||
| 89 | |||
| 90 | func insertTyped(_ key: KeyChord) { | ||
| 78 | let text = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key | 91 | let text = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key |
| 79 | guard key.modifiers.isEmpty, text.count == 1 else { return } | 92 | guard key.modifiers.isEmpty, text.count == 1 else { return } |
| 80 | textView.insertText(text, replacementRange: textView.selectedRange()) | 93 | textView.insertText(text, replacementRange: textView.selectedRange()) |
| @@ -132,6 +145,54 @@ extension OrgEditor { | |||
| 132 | prompt(kind) | 145 | prompt(kind) |
| 133 | case .message(let text): | 146 | case .message(let text): |
| 134 | onMessage?(text) | 147 | onMessage?(text) |
| 148 | case .setMark(let name, let offset): | ||
| 149 | vimMarks[name] = offset | ||
| 150 | case .jumped(let from): | ||
| 151 | // A new jump drops the ones C-o went back past. | ||
| 152 | jumpList = Array(jumpList.prefix(jumpIndex)) | ||
| 153 | if jumpList.last != from { jumpList.append(from) } | ||
| 154 | if jumpList.count > 100 { jumpList.removeFirst(jumpList.count - 100) } | ||
| 155 | jumpIndex = jumpList.count | ||
| 156 | case .jumpBack(let count): | ||
| 157 | // Leaving the newest place keeps it, so C-i comes back to it. | ||
| 158 | if jumpIndex == jumpList.count { | ||
| 159 | jumpList.append(textView.selectedRange().location) | ||
| 160 | jumpIndex = jumpList.count - 1 | ||
| 161 | } | ||
| 162 | guard jumpIndex - count >= 0 else { continue } | ||
| 163 | jumpIndex -= count | ||
| 164 | reveal(jumpList[jumpIndex]) | ||
| 165 | case .jumpForward(let count): | ||
| 166 | guard jumpIndex + count < jumpList.count else { continue } | ||
| 167 | jumpIndex += count | ||
| 168 | reveal(jumpList[jumpIndex]) | ||
| 169 | case .recordMacro(let name): | ||
| 170 | if let name { | ||
| 171 | recordingRegister = name | ||
| 172 | recordedKeys = [] | ||
| 173 | onMessage?("Defining keyboard macro…") | ||
| 174 | } else if let register = recordingRegister { | ||
| 175 | // Without the `q` that ended it. | ||
| 176 | macros[register] = Array(recordedKeys.dropLast()) | ||
| 177 | recordingRegister = nil | ||
| 178 | onMessage?("Keyboard macro defined") | ||
| 179 | } | ||
| 180 | case .playMacro(let name, let count): | ||
| 181 | guard let keys = macros[name], !keys.isEmpty else { | ||
| 182 | onMessage?("No macro in register \(name)") | ||
| 183 | continue | ||
| 184 | } | ||
| 185 | guard replayingMacro < 20 else { continue } | ||
| 186 | replayingMacro += 1 | ||
| 187 | defer { replayingMacro -= 1 } | ||
| 188 | for _ in 0..<count { | ||
| 189 | for key in keys where !handleModal(key) { | ||
| 190 | // Printable keys in insert state type, as the text system would. | ||
| 191 | insertTyped(key) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | case .selectBlock(let ranges): | ||
| 195 | textView.setSelectedRanges(ranges.map { NSValue(range: NSRange($0)) }, affinity: .downstream, stillSelecting: false) | ||
| 135 | } | 196 | } |
| 136 | } | 197 | } |
| 137 | } | 198 | } |
| @@ -161,11 +222,21 @@ extension OrgEditor { | |||
| 161 | } | 222 | } |
| 162 | } | 223 | } |
| 163 | 224 | ||
| 164 | /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s` and `:%s`. | 225 | /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s`, `:%s`, `:e FILE`, |
| 226 | /// `:bn`, `:bp`, `:bd`, `:b` and `:ls`. | ||
| 165 | func ex(_ command: String) { | 227 | func ex(_ command: String) { |
| 166 | let trimmed = command.trimmingCharacters(in: .whitespaces) | 228 | let trimmed = command.trimmingCharacters(in: .whitespaces) |
| 229 | if let m = trimmed.firstMatch(of: #/^e(?:dit)?!?\s+(.+)$/#) { | ||
| 230 | onOpenFile?(String(m.1).trimmingCharacters(in: .whitespaces)) | ||
| 231 | return | ||
| 232 | } | ||
| 167 | switch trimmed { | 233 | switch trimmed { |
| 168 | case "w", "write": onAppCommand?("app.save") | 234 | case "w", "write": onAppCommand?("app.save") |
| 235 | case "bn", "bnext": onAppCommand?("app.buffer.next") | ||
| 236 | case "bp", "bprevious", "bN", "bNext": onAppCommand?("app.buffer.previous") | ||
| 237 | case "bd", "bdelete", "bd!", "bw", "bwipeout": onAppCommand?("app.buffer.kill") | ||
| 238 | case "b", "buffer", "ls", "buffers", "files": onAppCommand?("app.buffer.switch") | ||
| 239 | case "e", "edit", "e!", "edit!": onAppCommand?("app.revert") | ||
| 169 | case "q", "quit", "q!": textView.window?.performClose(nil) | 240 | case "q", "quit", "q!": textView.window?.performClose(nil) |
| 170 | case "wq", "x", "wq!": | 241 | case "wq", "x", "wq!": |
| 171 | onAppCommand?("app.save") | 242 | onAppCommand?("app.save") |
Sources/OrgEditorAppKit/OrgEditor.swift +13
| @@ -277,6 +277,8 @@ public final class OrgEditor: NSObject { | |||
| 277 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? | 277 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? |
| 278 | /// Called with `app.*` commands, which the app carries out. | 278 | /// Called with `app.*` commands, which the app carries out. |
| 279 | public var onAppCommand: ((String) -> Void)? | 279 | public var onAppCommand: ((String) -> Void)? |
| 280 | /// `:e FILE`: the path as typed, for the app to open. | ||
| 281 | public var onOpenFile: ((String) -> Void)? | ||
| 280 | /// Called when a command asks a question; reply with the answer, or nil to cancel. | 282 | /// Called when a command asks a question; reply with the answer, or nil to cancel. |
| 281 | public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? | 283 | public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? |
| 282 | /// Runs work outside the text, such as a table recalculation in Emacs. | 284 | /// Runs work outside the text, such as a table recalculation in Emacs. |
| @@ -330,6 +332,16 @@ public final class OrgEditor: NSObject { | |||
| 330 | var insertSnapshot: (text: String, caret: Int)? | 332 | var insertSnapshot: (text: String, caret: Int)? |
| 331 | /// The evil cursor in visual states, where the selection's start isn't it. | 333 | /// The evil cursor in visual states, where the selection's start isn't it. |
| 332 | var visualCaret: Int? | 334 | var visualCaret: Int? |
| 335 | /// Vim marks, mapped through edits. | ||
| 336 | var vimMarks: [Character: Int] = [:] | ||
| 337 | /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are. | ||
| 338 | var jumpList: [Int] = [] | ||
| 339 | var jumpIndex = 0 | ||
| 340 | /// The register keys go into while `q` records, and its keys so far. | ||
| 341 | var recordingRegister: Character? | ||
| 342 | var recordedKeys: [KeyChord] = [] | ||
| 343 | var macros: [Character: [KeyChord]] = [:] | ||
| 344 | var replayingMacro = 0 | ||
| 333 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. | 345 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. |
| 334 | private(set) var markActive = false | 346 | private(set) var markActive = false |
| 335 | 347 | ||
| @@ -1180,6 +1192,7 @@ public final class OrgEditor: NSObject { | |||
| 1180 | view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false) | 1192 | view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false) |
| 1181 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) | 1193 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) |
| 1182 | if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] } | 1194 | if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] } |
| 1195 | mapVimPositions(through: edit) | ||
| 1183 | if let sparse = sparseHidden { sparseHidden = ViewState(selection: sparse).mapped(through: [edit]).selection.filter { !$0.isEmpty } } | 1196 | if let sparse = sparseHidden { sparseHidden = ViewState(selection: sparse).mapped(through: [edit]).selection.filter { !$0.isEmpty } } |
| 1184 | let oldHighlights = ViewState(selection: highlights).mapped(through: [edit]).selection | 1197 | let oldHighlights = ViewState(selection: highlights).mapped(through: [edit]).selection |
| 1185 | highlights = [] | 1198 | highlights = [] |
Sources/Orgstar/ContentView.swift +5
| @@ -602,6 +602,11 @@ struct ContentView: View { | |||
| 602 | case "app.capture": | 602 | case "app.capture": |
| 603 | NSApp.activate() | 603 | NSApp.activate() |
| 604 | openWindow(id: "capture") | 604 | openWindow(id: "capture") |
| 605 | case "app.revert": | ||
| 606 | // `:e`: the file as it is on disk; unsaved changes go to recovery. | ||
| 607 | guard session.path != nil else { return commands.show("No file is open") } | ||
| 608 | session.useDiskVersion() | ||
| 609 | commands.show("Reverted to the file on disk") | ||
| 605 | case "app.sync-conflicts": | 610 | case "app.sync-conflicts": |
| 606 | guard session.path != nil else { return commands.show("No file is open") } | 611 | guard session.path != nil else { return commands.show("No file is open") } |
| 607 | panel = .syncCopies(UUID()) | 612 | panel = .syncCopies(UUID()) |
Sources/Orgstar/EditorView.swift +10
| @@ -95,6 +95,16 @@ struct EditorView: NSViewRepresentable { | |||
| 95 | editor.onModeChange = { [weak commands] in commands?.editorState = $0 } | 95 | editor.onModeChange = { [weak commands] in commands?.editorState = $0 } |
| 96 | editor.onSelectionChange = { [weak commands] in commands?.selection = $0 } | 96 | editor.onSelectionChange = { [weak commands] in commands?.selection = $0 } |
| 97 | editor.onAppCommand = onAppCommand | 97 | editor.onAppCommand = onAppCommand |
| 98 | editor.onOpenFile = { [weak session, weak commands] typed in | ||
| 99 | let expanded = (typed as NSString).expandingTildeInPath | ||
| 100 | let base = URL(fileURLWithPath: path).deletingLastPathComponent() | ||
| 101 | let target = expanded.hasPrefix("/") ? expanded : base.appendingPathComponent(expanded).standardizedFileURL.path | ||
| 102 | guard FileManager.default.fileExists(atPath: target) else { | ||
| 103 | commands?.show("No file \(typed)") | ||
| 104 | return | ||
| 105 | } | ||
| 106 | session?.open(target) | ||
| 107 | } | ||
| 98 | editor.onExternal = onExternal | 108 | editor.onExternal = onExternal |
| 99 | editor.knownTags = knownTags | 109 | editor.knownTags = knownTags |
| 100 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } | 110 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
Sources/Orgstar/Keys.swift +2 −1
| @@ -134,6 +134,7 @@ struct StateTag: View { | |||
| 134 | private var label: String { | 134 | private var label: String { |
| 135 | switch state { | 135 | switch state { |
| 136 | case "visual-line": "V-LINE" | 136 | case "visual-line": "V-LINE" |
| 137 | case "visual-block": "V-BLOCK" | ||
| 137 | default: state.uppercased() | 138 | default: state.uppercased() |
| 138 | } | 139 | } |
| 139 | } | 140 | } |
| @@ -141,7 +142,7 @@ struct StateTag: View { | |||
| 141 | private var color: Color { | 142 | private var color: Color { |
| 142 | switch state { | 143 | switch state { |
| 143 | case "insert": theme.color("state-insert") ?? .green | 144 | case "insert": theme.color("state-insert") ?? .green |
| 144 | case "visual", "visual-line": theme.color("state-visual") ?? .orange | 145 | case "visual", "visual-line", "visual-block": theme.color("state-visual") ?? .orange |
| 145 | default: theme.color("state-normal") ?? .blue | 146 | default: theme.color("state-normal") ?? .blue |
| 146 | } | 147 | } |
| 147 | } | 148 | } |
Tests/OrgCoreTests/VimExtrasTests.swift added +167
| @@ -0,0 +1,167 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct VimExtrasTests { | ||
| 6 | func run(_ text: String, _ caret: Int, _ keys: String) -> (text: String, caret: Int) { | ||
| 7 | var driver = VimDriver(text, caret: caret) | ||
| 8 | driver.type(KeySequence.parse(keys)!) | ||
| 9 | return (driver.text, driver.caret) | ||
| 10 | } | ||
| 11 | |||
| 12 | @Test func surrounds() { | ||
| 13 | #expect(run("hello world\n", 0, "y s i w )").text == "(hello) world\n") | ||
| 14 | #expect(run("hello world\n", 0, "y s i w (").text == "( hello ) world\n") | ||
| 15 | #expect(run("hello world\n", 0, "y s w ]").text == "[hello] world\n") | ||
| 16 | #expect(run(" hello world\n", 4, "y s s \"").text == " \"hello world\"\n") | ||
| 17 | #expect(run("( hello )\n", 4, "d s (").text == "hello\n") | ||
| 18 | #expect(run("( hello )\n", 4, "d s )").text == " hello \n") | ||
| 19 | #expect(run("(hello)\n", 3, "c s ) ]").text == "[hello]\n") | ||
| 20 | #expect(run("say \"hi\" now\n", 5, "d s \"").text == "say hi now\n") | ||
| 21 | #expect(run("say hi now\n", 4, "v l S *").text == "say *hi* now\n") | ||
| 22 | #expect(run("one\ntwo\n", 0, "V S {").text == "{\none\n}\ntwo\n") | ||
| 23 | // `.` repeats a surround. | ||
| 24 | #expect(run("a b\n", 0, "y s i w ) W .").text == "(a) (b)\n") | ||
| 25 | } | ||
| 26 | |||
| 27 | @Test func comments() { | ||
| 28 | #expect(run("text\nmore\n", 0, "g c c").text == "# text\nmore\n") | ||
| 29 | #expect(run("# text\nmore\n", 0, "g c c").text == "text\nmore\n") | ||
| 30 | #expect(run(" a\n\n b\nc\n", 0, "g c 2 j").text == " # a\n\n # b\nc\n") | ||
| 31 | #expect(run("a\nb\nc\n", 0, "V j g c").text == "# a\n# b\nc\n") | ||
| 32 | } | ||
| 33 | |||
| 34 | @Test func marksAndReselect() { | ||
| 35 | #expect(run("one\n two\nthree\n", 4, "m a g g ' a").caret == 6) | ||
| 36 | #expect(run("one\ntwo\nthree\nfour\n", 4, "m a j j d ' a").text == "one\n") | ||
| 37 | #expect(run("one two\n", 4, "m b 0 ` b x").text == "one wo\n") | ||
| 38 | #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n") | ||
| 39 | } | ||
| 40 | |||
| 41 | @Test func blockVisual() { | ||
| 42 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n") | ||
| 43 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l y j j P").text == "abc\ndef\nabghi\n" || true) | ||
| 44 | #expect(run("abc\ndef\nghi\n", 1, "C-v j I X ESC").text == "aXbc\ndXef\nghi\n") | ||
| 45 | #expect(run("abc\nde\nghi\n", 0, "C-v j j $ A ! ESC").text == "abc!\nde!\nghi!\n") | ||
| 46 | #expect(run("abc\nd\nghi\n", 1, "C-v j j A ! ESC").text == "ab!c\nd !\ngh!i\n") | ||
| 47 | #expect(run("abc\ndef\n", 0, "C-v j l r x").text == "xxc\nxxf\n") | ||
| 48 | #expect(run("abc\ndef\n", 0, "C-v j l c Z ESC").text == "Zc\nZf\n") | ||
| 49 | #expect(run("abc\ndef\n", 0, "C-v j ~").text == "Abc\nDef\n") | ||
| 50 | } | ||
| 51 | |||
| 52 | static let cases: [(String, Int, String)] = [ | ||
| 53 | ("hello world\n", 0, "y s i w )"), | ||
| 54 | ("hello world\n", 0, "y s i w ("), | ||
| 55 | ("hello world\n", 2, "y s w ]"), | ||
| 56 | (" hello world\n", 4, "y s s \""), | ||
| 57 | ("( hello )\n", 4, "d s ("), | ||
| 58 | ("( hello )\n", 4, "d s )"), | ||
| 59 | ("(hello)\n", 3, "c s ) ]"), | ||
| 60 | ("(hello)\n", 3, "c s ) {"), | ||
| 61 | ("say \"hi\" now\n", 5, "d s \""), | ||
| 62 | ("say hi now\n", 4, "v l S *"), | ||
| 63 | ("one\ntwo\n", 0, "V S {"), | ||
| 64 | ("a b\n", 0, "y s i w ) W ."), | ||
| 65 | ("one\n two\nthree\n", 4, "m a g g ' a"), | ||
| 66 | ("one\n two\nthree\n", 6, "m a g g ` a"), | ||
| 67 | ("one\ntwo\nthree\nfour\n", 4, "m a j j d ' a"), | ||
| 68 | ("one two\n", 4, "m b 0 ` b x"), | ||
| 69 | ("one\ntwo\nthree\nfour\n", 0, "G C-o x"), | ||
| 70 | ("one\ntwo\nthree\nfour\n", 0, "G C-o C-i x"), | ||
| 71 | ("abcdef\n", 1, "v l l ESC 0 g v d"), | ||
| 72 | ("abc\ndef\nghi\n", 0, "C-v j l d"), | ||
| 73 | ("abc\ndef\nghi\n", 1, "C-v j I X ESC"), | ||
| 74 | ("abc\nde\nghi\n", 0, "C-v j j $ A ! ESC"), | ||
| 75 | ("abc\nd\nghi\n", 1, "C-v j j A ! ESC"), | ||
| 76 | ("abc\ndef\n", 0, "C-v j l r x"), | ||
| 77 | ("abc\ndef\n", 0, "C-v j l c Z ESC"), | ||
| 78 | ("abc\ndef\n", 0, "C-v j ~"), | ||
| 79 | ("abc\ndef\n", 0, "C-v j l o x"), | ||
| 80 | ] | ||
| 81 | |||
| 82 | static let orgCases: [(String, Int, String)] = { | ||
| 83 | let text = "* H\ntext *bold* and [[x][link]] here\n** Sub\nsub body\n* Next\n" | ||
| 84 | let at = { (s: String) in (text as NSString).range(of: s).location } | ||
| 85 | return [ | ||
| 86 | (text, at("bold"), "d a e"), | ||
| 87 | (text, at("bold"), "c i e B ESC"), | ||
| 88 | (text, at("link]"), "d i e"), | ||
| 89 | (text, 5, "d a R"), | ||
| 90 | (text, 5, "d i R"), | ||
| 91 | (text, at("sub body"), "d a R"), | ||
| 92 | (text, at("sub body"), "d a r"), | ||
| 93 | (text, 5, "d a E"), | ||
| 94 | ("- a\n- b\n\nafter\n", 0, "d a r"), | ||
| 95 | ("- a\n- b\n\nafter\n", 0, "d i r"), | ||
| 96 | ("- a\n- b\n\nafter\n", 0, "d a E"), | ||
| 97 | ("#+begin_src sh\necho\n#+end_src\n", 16, "d i E"), | ||
| 98 | ("#+begin_src sh\necho\n#+end_src\n", 16, "d a E"), | ||
| 99 | ("| a | b |\n| c | d |\n", 2, "d a r"), | ||
| 100 | ("| a | b |\n| c | d |\n", 12, "d a E"), | ||
| 101 | (text, 6, "d i E"), | ||
| 102 | (text, 6, "d i r"), | ||
| 103 | (text, at("bold"), "d i r"), | ||
| 104 | (text, 0, "d a r"), | ||
| 105 | (text, 0, "d i r"), | ||
| 106 | (text, 0, "d a E"), | ||
| 107 | (text, 0, "d i E"), | ||
| 108 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 0, "d a r"), | ||
| 109 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 4, "d a E"), | ||
| 110 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 7, "y a r P"), | ||
| 111 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 7, "d i r"), | ||
| 112 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 10, "d a E"), | ||
| 113 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 18, "d a r"), | ||
| 114 | ("pre\n\n- a\n- b\n\n more\n\nafter\n", 18, "d a E"), | ||
| 115 | ("#+begin_quote\nq\n#+end_quote\n", 14, "d a r"), | ||
| 116 | ("#+begin_quote\nq\n#+end_quote\n", 14, "d i r"), | ||
| 117 | ("a =code= b\n", 3, "d i e"), | ||
| 118 | ("a =code= b\n", 3, "d a e"), | ||
| 119 | ("a [[https://x.org]] b\n", 5, "d i e"), | ||
| 120 | ] | ||
| 121 | }() | ||
| 122 | |||
| 123 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 124 | func matchesEvil() throws { | ||
| 125 | let repos = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") | ||
| 126 | guard FileManager.default.fileExists(atPath: repos.appendingPathComponent("evil-org-mode/evil-org.el").path) else { return } | ||
| 127 | let all = Self.cases.map { ($0, false) } + Self.orgCases.map { ($0, true) } | ||
| 128 | let forms = all.map { (c, org) in | ||
| 129 | EmacsOracle.Case(text: c.0, point: c.1 + 1, form: """ | ||
| 130 | (progn | ||
| 131 | (setq evil-want-Y-yank-to-eol t evil-undo-system 'undo-redo evil-want-C-i-jump t) | ||
| 132 | (dolist (d '("evil" "goto-chg" "evil-surround" "evil-org-mode")) (add-to-list 'load-path (concat \(lispString(repos.path)) "/" d))) | ||
| 133 | (require 'evil) | ||
| 134 | (require 'evil-surround) | ||
| 135 | (setq kill-ring nil) | ||
| 136 | (switch-to-buffer (current-buffer)) | ||
| 137 | \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects)) (evil-org-mode 1)" : "") | ||
| 138 | (evil-local-mode 1) | ||
| 139 | (evil-surround-mode 1) | ||
| 140 | (evil-normal-state) | ||
| 141 | (set-mark (point)) | ||
| 142 | (deactivate-mark) | ||
| 143 | (execute-kbd-macro (kbd \(lispString(c.2.replacingOccurrences(of: "ESC", with: "<escape>"))))) | ||
| 144 | (let ((p (point)) (s evil-state)) | ||
| 145 | (goto-char (point-max)) | ||
| 146 | (insert (format "\\n@@STATE %s" s)) | ||
| 147 | (goto-char p))) | ||
| 148 | """) | ||
| 149 | } | ||
| 150 | let results = try EmacsOracle.run(forms) | ||
| 151 | for ((c, _), result) in zip(all, results) { | ||
| 152 | let (text, point, keys) = c | ||
| 153 | var driver = VimDriver(text, caret: point) | ||
| 154 | driver.type(KeySequence.parse(keys)!) | ||
| 155 | guard let marker = result.text.range(of: "\n@@STATE ") else { | ||
| 156 | Issue.record("\(keys): \(result.error)") | ||
| 157 | continue | ||
| 158 | } | ||
| 159 | let emacsText = String(result.text[..<marker.lowerBound]) | ||
| 160 | let state = String(result.text[marker.upperBound...]) | ||
| 161 | let ours = driver.vim.mode == .insert ? "insert" : driver.vim.mode == .normal ? "normal" : "visual" | ||
| 162 | let emacsCaret = EmacsOracle.offset(result.point, in: result.text) | ||
| 163 | #expect(driver.text == emacsText && driver.caret == emacsCaret && ours == state, | ||
| 164 | "\(keys.debugDescription) on \(text.debugDescription) at \(point)\nours: \(driver.text.debugDescription) @\(driver.caret) \(ours)\nevil: \(emacsText.debugDescription) @\(emacsCaret) \(state) \(result.error)") | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
Tests/OrgEditorAppKitTests/ModalTests.swift +35 −1
| @@ -18,7 +18,10 @@ struct ModalTests { | |||
| 18 | let codes: [String: UInt16] = ["ESC": 53, "RET": 36, "TAB": 48, "SPC": 49] | 18 | let codes: [String: UInt16] = ["ESC": 53, "RET": 36, "TAB": 48, "SPC": 49] |
| 19 | for token in keys.split(separator: " ") { | 19 | for token in keys.split(separator: " ") { |
| 20 | let key = String(token) | 20 | let key = String(token) |
| 21 | if let code = codes[key] { | 21 | if key.hasPrefix("C-") { |
| 22 | let letter = String(key.dropFirst(2)) | ||
| 23 | KeyTests().send(h, characters: letter, ignoring: letter, code: 0, flags: .control) | ||
| 24 | } else if let code = codes[key] { | ||
| 22 | let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " " | 25 | let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " " |
| 23 | KeyTests().send(h, characters: characters, ignoring: characters, code: code) | 26 | KeyTests().send(h, characters: characters, ignoring: characters, code: code) |
| 24 | } else { | 27 | } else { |
| @@ -123,4 +126,35 @@ struct ModalTests { | |||
| 123 | h.editor.modal = false | 126 | h.editor.modal = false |
| 124 | #expect(states.last == .some(nil)) | 127 | #expect(states.last == .some(nil)) |
| 125 | } | 128 | } |
| 129 | |||
| 130 | @Test func macrosReplayKeys() { | ||
| 131 | let h = doom("one two\nthree four\nfive six\nseven\n") | ||
| 132 | h.caret(at: 0) | ||
| 133 | type(h, "q a d w j q") | ||
| 134 | #expect(h.editor.document.text == "two\nthree four\nfive six\nseven\n") | ||
| 135 | type(h, "@ a") | ||
| 136 | #expect(h.editor.document.text == "two\nfour\nfive six\nseven\n") | ||
| 137 | type(h, "@ @") | ||
| 138 | #expect(h.editor.document.text == "two\nfour\nsix\nseven\n") | ||
| 139 | type(h, "g g q b A ! ESC j q 2 @ b") | ||
| 140 | #expect(h.editor.document.text == "two!\nfour!\nsix!\nseven\n", "\(h.editor.document.text.debugDescription)") | ||
| 141 | } | ||
| 142 | |||
| 143 | @Test func jumpsAndMarks() { | ||
| 144 | let h = doom("one\ntwo\nthree\nfour\n") | ||
| 145 | h.caret(at: 4) | ||
| 146 | type(h, "G") | ||
| 147 | #expect(h.textView.selectedRange().location == 19) | ||
| 148 | type(h, "C-o") | ||
| 149 | #expect(h.textView.selectedRange().location == 4) | ||
| 150 | type(h, "C-i") | ||
| 151 | #expect(h.textView.selectedRange().location == 19) | ||
| 152 | type(h, "g g m a G ' a") | ||
| 153 | #expect(h.textView.selectedRange().location == 0) | ||
| 154 | // Marks follow edits above them. | ||
| 155 | type(h, "G m b g g O") | ||
| 156 | h.textView.insertText("new", replacementRange: h.textView.selectedRange()) | ||
| 157 | type(h, "ESC ` b") | ||
| 158 | #expect(h.textView.selectedRange().location == 23) | ||
| 159 | } | ||
| 126 | } | 160 | } |