| 1 | // Vim grammar tests. `node --test node-tui/vim.test.ts` |
| 2 | // |
| 3 | // The editing layer is a pure reducer precisely so it can be checked without a |
| 4 | // terminal or an audio context — which matters, because "did `d2w` delete the |
| 5 | // right span" is not a question you want to answer by squinting at a TUI. |
| 6 | import { test } from 'node:test'; |
| 7 | import assert from 'node:assert/strict'; |
| 8 | |
| 9 | import * as vim from './vim.ts'; |
| 10 | import * as buf from './buffer.ts'; |
| 11 | |
| 12 | const KEY: vim.Key = { |
| 13 | input: '', ctrl: false, escape: false, return: false, backspace: false, |
| 14 | tab: false, leftArrow: false, rightArrow: false, upArrow: false, downArrow: false, |
| 15 | }; |
| 16 | |
| 17 | /** Feed a string of keystrokes. `<esc>` and `<cr>` stand in for those keys. */ |
| 18 | function press(state: vim.VimState, keys: string): vim.VimState { |
| 19 | let current = state; |
| 20 | const tokens = keys.match(/<esc>|<cr>|[\s\S]/g) ?? []; |
| 21 | for (const token of tokens) { |
| 22 | const key: vim.Key = |
| 23 | token === '<esc>' ? { ...KEY, escape: true } |
| 24 | : token === '<cr>' ? { ...KEY, return: true } |
| 25 | : { ...KEY, input: token }; |
| 26 | current = vim.handleKey(current, key).state; |
| 27 | } |
| 28 | return current; |
| 29 | } |
| 30 | |
| 31 | const text = (state: vim.VimState): string => buf.toText(state.buffer); |
| 32 | const start = (source: string): vim.VimState => vim.createVimState(source); |
| 33 | |
| 34 | test('starts in normal mode and does not insert text', () => { |
| 35 | const state = press(start('hello'), 'jkl'); |
| 36 | assert.equal(text(state), 'hello'); |
| 37 | assert.equal(state.mode, 'normal'); |
| 38 | }); |
| 39 | |
| 40 | test('i enters insert mode and types', () => { |
| 41 | const state = press(start('bd'), 'iX'); |
| 42 | assert.equal(text(state), 'Xbd'); |
| 43 | assert.equal(state.mode, 'insert'); |
| 44 | }); |
| 45 | |
| 46 | test('escape leaves insert mode and steps the cursor back', () => { |
| 47 | const state = press(start('bd'), 'iX<esc>'); |
| 48 | assert.equal(state.mode, 'normal'); |
| 49 | assert.equal(buf.cursorOffset(state.buffer), 0); |
| 50 | }); |
| 51 | |
| 52 | test('A appends at end of line', () => { |
| 53 | assert.equal(text(press(start('s("bd")'), 'A.fast(2)<esc>')), 's("bd").fast(2)'); |
| 54 | }); |
| 55 | |
| 56 | test('x deletes the character under the cursor', () => { |
| 57 | assert.equal(text(press(start('abc'), 'x')), 'bc'); |
| 58 | }); |
| 59 | |
| 60 | test('x with a count deletes several', () => { |
| 61 | assert.equal(text(press(start('abcdef'), '3x')), 'def'); |
| 62 | }); |
| 63 | |
| 64 | test('dw deletes to the start of the next word', () => { |
| 65 | assert.equal(text(press(start('foo bar baz'), 'dw')), 'bar baz'); |
| 66 | }); |
| 67 | |
| 68 | test('d2w takes a count between operator and motion', () => { |
| 69 | assert.equal(text(press(start('foo bar baz'), 'd2w')), 'baz'); |
| 70 | }); |
| 71 | |
| 72 | test('de is inclusive of the word end', () => { |
| 73 | assert.equal(text(press(start('foo bar'), 'de')), ' bar'); |
| 74 | }); |
| 75 | |
| 76 | test('dd deletes the whole line', () => { |
| 77 | assert.equal(text(press(start('one\ntwo\nthree'), 'jdd')), 'one\nthree'); |
| 78 | }); |
| 79 | |
| 80 | test('2dd deletes two lines', () => { |
| 81 | assert.equal(text(press(start('one\ntwo\nthree'), '2dd')), 'three'); |
| 82 | }); |
| 83 | |
| 84 | test('dd on the last line does not leave a blank', () => { |
| 85 | assert.equal(text(press(start('one\ntwo'), 'jdd')), 'one'); |
| 86 | }); |
| 87 | |
| 88 | test('D deletes to end of line, keeping the line', () => { |
| 89 | assert.equal(text(press(start('foo bar\nbaz'), 'wD')), 'foo \nbaz'); |
| 90 | }); |
| 91 | |
| 92 | test('cw changes a word and lands in insert mode', () => { |
| 93 | const state = press(start('foo bar'), 'cwqux'); |
| 94 | assert.equal(text(state), 'qux bar'); |
| 95 | assert.equal(state.mode, 'insert'); |
| 96 | }); |
| 97 | |
| 98 | test('o opens a line below and inherits indentation', () => { |
| 99 | const state = press(start(' s("bd")'), 'o.fast(2)'); |
| 100 | assert.equal(text(state), ' s("bd")\n .fast(2)'); |
| 101 | }); |
| 102 | |
| 103 | test('O opens a line above', () => { |
| 104 | assert.equal(text(press(start('two'), 'Oone<esc>')), 'one\ntwo'); |
| 105 | }); |
| 106 | |
| 107 | test('yy then p duplicates a line below', () => { |
| 108 | assert.equal(text(press(start('one\ntwo'), 'yyp')), 'one\none\ntwo'); |
| 109 | }); |
| 110 | |
| 111 | test('dd then P puts the line back where it was', () => { |
| 112 | assert.equal(text(press(start('one\ntwo'), 'ddP')), 'one\ntwo'); |
| 113 | }); |
| 114 | |
| 115 | test('u undoes an edit', () => { |
| 116 | assert.equal(text(press(start('one\ntwo'), 'ddu')), 'one\ntwo'); |
| 117 | }); |
| 118 | |
| 119 | test('undo is not confused by cursor-only motions', () => { |
| 120 | assert.equal(text(press(start('abc'), 'xlllu')), 'abc'); |
| 121 | }); |
| 122 | |
| 123 | test('0 and $ move within the line', () => { |
| 124 | const state = press(start('hello'), '$'); |
| 125 | assert.equal(state.buffer.col, 4); |
| 126 | assert.equal(press(state, '0').buffer.col, 0); |
| 127 | }); |
| 128 | |
| 129 | test('a bare 0 is a motion, but 10 is a count', () => { |
| 130 | const ten = press(start('a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk'), '10j'); |
| 131 | assert.equal(ten.buffer.row, 10); |
| 132 | }); |
| 133 | |
| 134 | test('gg and G jump to the first and last line', () => { |
| 135 | const state = press(start('one\ntwo\nthree'), 'G'); |
| 136 | assert.equal(state.buffer.row, 2); |
| 137 | assert.equal(press(state, 'gg').buffer.row, 0); |
| 138 | }); |
| 139 | |
| 140 | test('G with a count goes to that line', () => { |
| 141 | assert.equal(press(start('one\ntwo\nthree'), '2G').buffer.row, 1); |
| 142 | }); |
| 143 | |
| 144 | test('the cursor may not rest past the last character in normal mode', () => { |
| 145 | const state = press(start('ab\n'), '$lll'); |
| 146 | assert.equal(state.buffer.col, 1); |
| 147 | }); |
| 148 | |
| 149 | test('visual mode selects and deletes', () => { |
| 150 | const state = press(start('foo bar'), 'vlld'); |
| 151 | assert.equal(text(state), ' bar'); |
| 152 | assert.equal(state.mode, 'normal'); |
| 153 | }); |
| 154 | |
| 155 | test('V selects whole lines', () => { |
| 156 | assert.equal(text(press(start('one\ntwo\nthree'), 'Vjd')), 'three'); |
| 157 | }); |
| 158 | |
| 159 | test('ctrl-e requests evaluation without touching the buffer', () => { |
| 160 | const result = vim.handleKey(start('s("bd")'), { ...KEY, ctrl: true, input: 'e' }); |
| 161 | assert.equal(result.effects.evaluate, true); |
| 162 | assert.equal(text(result.state), 's("bd")'); |
| 163 | }); |
| 164 | |
| 165 | test('ctrl-e works from insert mode too', () => { |
| 166 | const state = press(start('x'), 'i'); |
| 167 | const result = vim.handleKey(state, { ...KEY, ctrl: true, input: 'e' }); |
| 168 | assert.equal(result.effects.evaluate, true); |
| 169 | assert.equal(result.state.mode, 'insert'); |
| 170 | }); |
| 171 | |
| 172 | test('enter evaluates from normal mode', () => { |
| 173 | const result = vim.handleKey(start('s("bd")'), { ...KEY, return: true }); |
| 174 | assert.equal(result.effects.evaluate, true); |
| 175 | assert.equal(text(result.state), 's("bd")'); |
| 176 | assert.equal(result.state.mode, 'normal'); |
| 177 | }); |
| 178 | |
| 179 | test('enter evaluates from visual mode and drops a half-typed count', () => { |
| 180 | const result = vim.handleKey(press(start('one\ntwo'), 'v2'), { ...KEY, return: true }); |
| 181 | assert.equal(result.effects.evaluate, true); |
| 182 | assert.equal(result.state.pending, ''); |
| 183 | assert.equal(text(result.state), 'one\ntwo'); |
| 184 | }); |
| 185 | |
| 186 | test('enter still inserts a newline in insert mode', () => { |
| 187 | const result = vim.handleKey(press(start('ab'), 'i'), { ...KEY, return: true }); |
| 188 | assert.equal(result.effects.evaluate, undefined); |
| 189 | assert.equal(text(result.state), '\nab'); |
| 190 | }); |
| 191 | |
| 192 | test('an unknown command is dropped rather than left pending', () => { |
| 193 | const state = press(start('abc'), 'Z'); |
| 194 | assert.equal(state.pending, ''); |
| 195 | assert.equal(text(state), 'abc'); |
| 196 | }); |
| 197 | |
| 198 | test('a partial operator stays pending', () => { |
| 199 | assert.equal(press(start('abc'), 'd').pending, 'd'); |
| 200 | assert.equal(press(start('abc'), 'd2').pending, 'd2'); |
| 201 | }); |
| 202 | |
| 203 | test('word motions stop at punctuation, as vim does', () => { |
| 204 | // The cursor should land on the `(`, not skip the whole call. |
| 205 | const state = press(start('s("bd")'), 'w'); |
| 206 | assert.equal(buf.cursorOffset(state.buffer), 1); |
| 207 | }); |
| 208 | |
| 209 | test('W treats punctuation as part of the word', () => { |
| 210 | const state = press(start('s("bd") fast'), 'W'); |
| 211 | assert.equal(buf.cursorOffset(state.buffer), 8); |
| 212 | }); |