ep_vim 0.12.0 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1118 @@
1
+ "use strict";
2
+
3
+ const { describe, it, beforeEach } = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+
6
+ // Mock navigator for clipboard operations
7
+ global.navigator = {
8
+ clipboard: {
9
+ writeText: () => Promise.resolve(),
10
+ },
11
+ };
12
+
13
+ const {
14
+ _state: state,
15
+ _handleKey: handleKey,
16
+ _commands: commands,
17
+ _parameterized: parameterized,
18
+ _setVimEnabled: setVimEnabled,
19
+ _setUseCtrlKeys: setUseCtrlKeys,
20
+ aceKeyEvent,
21
+ } = require("./index.js");
22
+
23
+ const makeRep = (lines) => ({
24
+ lines: {
25
+ length: () => lines.length,
26
+ atIndex: (n) => ({ text: lines[n] }),
27
+ },
28
+ });
29
+
30
+ const makeMockEditorInfo = () => {
31
+ const calls = [];
32
+ return {
33
+ editorInfo: {
34
+ ace_inCallStackIfNecessary: (_name, fn) => fn(),
35
+ ace_performSelectionChange: (start, end, _flag) => {
36
+ calls.push({ type: "select", start, end });
37
+ },
38
+ ace_updateBrowserSelectionFromRep: () => {},
39
+ ace_performDocumentReplaceRange: (start, end, newText) => {
40
+ calls.push({ type: "replace", start, end, newText });
41
+ },
42
+ },
43
+ calls,
44
+ };
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const resetState = () => {
50
+ state.mode = "normal";
51
+ state.pendingKey = null;
52
+ state.pendingCount = null;
53
+ state.countBuffer = "";
54
+ state.register = null;
55
+ state.namedRegisters = {};
56
+ state.pendingRegister = null;
57
+ state.awaitingRegister = false;
58
+ state.marks = {};
59
+ state.lastCharSearch = null;
60
+ state.visualAnchor = null;
61
+ state.visualCursor = null;
62
+ state.editorDoc = null;
63
+ state.currentRep = null;
64
+ state.desiredColumn = null;
65
+ state.lastCommand = null;
66
+ state.searchMode = false;
67
+ state.searchBuffer = "";
68
+ state.searchDirection = null;
69
+ state.lastSearch = null;
70
+ state.lastVisualSelection = null;
71
+ };
72
+
73
+ describe("delete operations", () => {
74
+ beforeEach(() => {
75
+ state.mode = "normal";
76
+ state.pendingKey = null;
77
+ state.pendingCount = null;
78
+ state.countBuffer = "";
79
+ state.register = null;
80
+ state.marks = {};
81
+ state.lastCharSearch = null;
82
+ state.visualAnchor = null;
83
+ state.visualCursor = null;
84
+ state.editorDoc = null;
85
+ state.currentRep = null;
86
+ state.desiredColumn = null;
87
+ state.lastCommand = null;
88
+ state.searchMode = false;
89
+ state.searchBuffer = "";
90
+ state.searchDirection = null;
91
+ state.lastSearch = null;
92
+ });
93
+
94
+ it("x deletes character at cursor", () => {
95
+ const rep = makeRep(["hello"]);
96
+ const { editorInfo } = makeMockEditorInfo();
97
+
98
+ const ctx = {
99
+ rep,
100
+ editorInfo,
101
+ line: 0,
102
+ char: 1,
103
+ lineText: "hello",
104
+ count: 1,
105
+ };
106
+ commands.normal["x"](ctx);
107
+
108
+ assert.equal(state.register, "e");
109
+ });
110
+
111
+ it("x with count deletes multiple characters", () => {
112
+ const rep = makeRep(["hello"]);
113
+ const { editorInfo } = makeMockEditorInfo();
114
+
115
+ const ctx = {
116
+ rep,
117
+ editorInfo,
118
+ line: 0,
119
+ char: 1,
120
+ lineText: "hello",
121
+ count: 3,
122
+ };
123
+ commands.normal["x"](ctx);
124
+
125
+ assert.equal(state.register, "ell");
126
+ });
127
+
128
+ it("dd deletes entire line", () => {
129
+ const rep = makeRep(["line1", "line2", "line3"]);
130
+ const { editorInfo } = makeMockEditorInfo();
131
+
132
+ const ctx = {
133
+ rep,
134
+ editorInfo,
135
+ line: 1,
136
+ char: 0,
137
+ lineText: "line2",
138
+ count: 1,
139
+ };
140
+ commands.normal["dd"](ctx);
141
+
142
+ assert.deepEqual(state.register, ["line2"]);
143
+ });
144
+
145
+ it("yy yanks entire line to register", () => {
146
+ const rep = makeRep(["line1", "line2", "line3"]);
147
+ const { editorInfo, calls } = makeMockEditorInfo();
148
+
149
+ const ctx = {
150
+ rep,
151
+ editorInfo,
152
+ line: 1,
153
+ char: 0,
154
+ lineText: "line2",
155
+ count: 1,
156
+ };
157
+ commands.normal["yy"](ctx);
158
+
159
+ assert.equal(state.register.length, 1);
160
+ assert.equal(state.register[0], "line2");
161
+ });
162
+
163
+ it("cc changes entire line (deletes and enters insert)", () => {
164
+ const rep = makeRep(["line1", "line2", "line3"]);
165
+ const { editorInfo, calls } = makeMockEditorInfo();
166
+
167
+ const ctx = {
168
+ rep,
169
+ editorInfo,
170
+ line: 1,
171
+ char: 0,
172
+ lineText: "line2",
173
+ count: 1,
174
+ };
175
+ commands.normal["cc"](ctx);
176
+
177
+ assert.equal(state.mode, "insert");
178
+ });
179
+
180
+ it("D deletes from cursor to end of line", () => {
181
+ const rep = makeRep(["hello world"]);
182
+ const { editorInfo } = makeMockEditorInfo();
183
+
184
+ const ctx = {
185
+ rep,
186
+ editorInfo,
187
+ line: 0,
188
+ char: 6,
189
+ lineText: "hello world",
190
+ };
191
+ commands.normal["D"](ctx);
192
+
193
+ assert.equal(state.register, "world");
194
+ });
195
+
196
+ it("J joins lines", () => {
197
+ const rep = makeRep(["hello", "world"]);
198
+ const { editorInfo } = makeMockEditorInfo();
199
+
200
+ const ctx = {
201
+ rep,
202
+ editorInfo,
203
+ line: 0,
204
+ char: 0,
205
+ lineText: "hello",
206
+ count: 1,
207
+ };
208
+ commands.normal["J"](ctx);
209
+
210
+ assert.equal(state.mode, "normal");
211
+ });
212
+ });
213
+
214
+ describe("replace command", () => {
215
+ beforeEach(() => {
216
+ state.mode = "normal";
217
+ state.pendingKey = null;
218
+ state.pendingCount = null;
219
+ state.countBuffer = "";
220
+ state.register = null;
221
+ state.marks = {};
222
+ state.lastCharSearch = null;
223
+ state.visualAnchor = null;
224
+ state.visualCursor = null;
225
+ state.editorDoc = null;
226
+ state.currentRep = null;
227
+ state.desiredColumn = null;
228
+ state.lastCommand = null;
229
+ state.searchMode = false;
230
+ state.searchBuffer = "";
231
+ state.searchDirection = null;
232
+ state.lastSearch = null;
233
+ });
234
+
235
+ it("r enters pending mode for replace", () => {
236
+ const ctx = { rep: makeRep([]), line: 0, char: 0, lineText: "" };
237
+ commands.normal["r"](ctx);
238
+
239
+ assert.equal(state.pendingKey, "r");
240
+ });
241
+
242
+ it("r replaces single character", () => {
243
+ const rep = makeRep(["hello"]);
244
+ const { editorInfo, calls } = makeMockEditorInfo();
245
+
246
+ const ctx = {
247
+ rep,
248
+ editorInfo,
249
+ line: 0,
250
+ char: 1,
251
+ lineText: "hello",
252
+ count: 1,
253
+ };
254
+ parameterized["r"]("x", ctx);
255
+
256
+ assert.equal(calls.length, 2);
257
+ assert.deepEqual(calls[0], {
258
+ type: "replace",
259
+ start: [0, 1],
260
+ end: [0, 2],
261
+ newText: "x",
262
+ });
263
+ });
264
+
265
+ it("r with count replaces multiple characters", () => {
266
+ const rep = makeRep(["hello"]);
267
+ const { editorInfo, calls } = makeMockEditorInfo();
268
+
269
+ const ctx = {
270
+ rep,
271
+ editorInfo,
272
+ line: 0,
273
+ char: 1,
274
+ lineText: "hello",
275
+ count: 3,
276
+ };
277
+ parameterized["r"]("x", ctx);
278
+
279
+ assert.equal(calls.length, 2);
280
+ });
281
+ });
282
+
283
+ describe("case toggle", () => {
284
+ beforeEach(() => {
285
+ state.mode = "normal";
286
+ state.pendingKey = null;
287
+ state.pendingCount = null;
288
+ state.countBuffer = "";
289
+ state.register = null;
290
+ state.marks = {};
291
+ state.lastCharSearch = null;
292
+ state.visualAnchor = null;
293
+ state.visualCursor = null;
294
+ state.editorDoc = null;
295
+ state.currentRep = null;
296
+ state.desiredColumn = null;
297
+ state.lastCommand = null;
298
+ state.searchMode = false;
299
+ state.searchBuffer = "";
300
+ state.searchDirection = null;
301
+ state.lastSearch = null;
302
+ });
303
+
304
+ it("~ toggles case of character", () => {
305
+ const rep = makeRep(["hello"]);
306
+ const { editorInfo } = makeMockEditorInfo();
307
+
308
+ const ctx = {
309
+ rep,
310
+ editorInfo,
311
+ line: 0,
312
+ char: 0,
313
+ lineText: "hello",
314
+ count: 1,
315
+ };
316
+ commands.normal["~"](ctx);
317
+
318
+ assert.equal(state.mode, "normal");
319
+ });
320
+
321
+ it("~ with count toggles multiple characters", () => {
322
+ const rep = makeRep(["hello"]);
323
+ const { editorInfo } = makeMockEditorInfo();
324
+
325
+ const ctx = {
326
+ rep,
327
+ editorInfo,
328
+ line: 0,
329
+ char: 0,
330
+ lineText: "hello",
331
+ count: 3,
332
+ };
333
+ commands.normal["~"](ctx);
334
+
335
+ assert.equal(state.mode, "normal");
336
+ });
337
+ });
338
+
339
+ describe("text objects", () => {
340
+ beforeEach(() => {
341
+ state.mode = "normal";
342
+ state.pendingKey = null;
343
+ state.pendingCount = null;
344
+ state.countBuffer = "";
345
+ state.register = null;
346
+ state.marks = {};
347
+ state.lastCharSearch = null;
348
+ state.visualAnchor = null;
349
+ state.visualCursor = null;
350
+ state.editorDoc = null;
351
+ state.currentRep = null;
352
+ state.desiredColumn = null;
353
+ state.lastCommand = null;
354
+ state.searchMode = false;
355
+ state.searchBuffer = "";
356
+ state.searchDirection = null;
357
+ state.lastSearch = null;
358
+ });
359
+
360
+ it("diw deletes inner word", () => {
361
+ const rep = makeRep(["hello world"]);
362
+ const { editorInfo } = makeMockEditorInfo();
363
+
364
+ const ctx = {
365
+ rep,
366
+ editorInfo,
367
+ line: 0,
368
+ char: 0,
369
+ lineText: "hello world",
370
+ };
371
+ commands.normal["diw"](ctx);
372
+ });
373
+
374
+ it("daw deletes a word", () => {
375
+ const rep = makeRep(["hello world"]);
376
+ const { editorInfo } = makeMockEditorInfo();
377
+
378
+ const ctx = {
379
+ rep,
380
+ editorInfo,
381
+ line: 0,
382
+ char: 0,
383
+ lineText: "hello world",
384
+ };
385
+ commands.normal["daw"](ctx);
386
+ });
387
+
388
+ it("yiw yanks inner word", () => {
389
+ const rep = makeRep(["hello world"]);
390
+ const { editorInfo } = makeMockEditorInfo();
391
+
392
+ const ctx = {
393
+ rep,
394
+ editorInfo,
395
+ line: 0,
396
+ char: 0,
397
+ lineText: "hello world",
398
+ };
399
+ commands.normal["yiw"](ctx);
400
+
401
+ assert.equal(typeof state.register, "string");
402
+ });
403
+
404
+ it("ci( changes inner parentheses", () => {
405
+ const rep = makeRep(["func(arg)"]);
406
+ const { editorInfo } = makeMockEditorInfo();
407
+
408
+ const ctx = {
409
+ rep,
410
+ editorInfo,
411
+ line: 0,
412
+ char: 5,
413
+ lineText: "func(arg)",
414
+ };
415
+ commands.normal["ci("](ctx);
416
+
417
+ assert.equal(state.mode, "insert");
418
+ });
419
+
420
+ it("ca[ changes around brackets", () => {
421
+ const rep = makeRep(["array[1]"]);
422
+ const { editorInfo } = makeMockEditorInfo();
423
+
424
+ const ctx = {
425
+ rep,
426
+ editorInfo,
427
+ line: 0,
428
+ char: 5,
429
+ lineText: "array[1]",
430
+ };
431
+ commands.normal["ca["](ctx);
432
+
433
+ assert.equal(state.mode, "insert");
434
+ });
435
+
436
+ it('di" deletes inner quotes', () => {
437
+ const rep = makeRep(['text "hello world" here']);
438
+ const { editorInfo } = makeMockEditorInfo();
439
+
440
+ const ctx = {
441
+ rep,
442
+ editorInfo,
443
+ line: 0,
444
+ char: 7,
445
+ lineText: 'text "hello world" here',
446
+ };
447
+ commands.normal['di"'](ctx);
448
+ });
449
+ });
450
+
451
+ describe("miscellaneous commands", () => {
452
+ beforeEach(() => {
453
+ state.mode = "normal";
454
+ state.pendingKey = null;
455
+ state.pendingCount = null;
456
+ state.countBuffer = "";
457
+ state.register = null;
458
+ state.marks = {};
459
+ state.lastCharSearch = null;
460
+ state.visualAnchor = null;
461
+ state.visualCursor = null;
462
+ state.editorDoc = null;
463
+ state.currentRep = null;
464
+ state.desiredColumn = null;
465
+ state.lastCommand = null;
466
+ state.searchMode = false;
467
+ state.searchBuffer = "";
468
+ state.searchDirection = null;
469
+ state.lastSearch = null;
470
+ });
471
+
472
+ it("Y yanks line", () => {
473
+ const rep = makeRep(["hello world"]);
474
+ const { editorInfo } = makeMockEditorInfo();
475
+
476
+ const ctx = {
477
+ rep,
478
+ editorInfo,
479
+ line: 0,
480
+ char: 0,
481
+ lineText: "hello world",
482
+ };
483
+ commands.normal["Y"](ctx);
484
+
485
+ assert.deepEqual(state.register, ["hello world"]);
486
+ });
487
+
488
+ it("operator with motion works (dh)", () => {
489
+ const rep = makeRep(["hello"]);
490
+ const { editorInfo } = makeMockEditorInfo();
491
+
492
+ const ctx = {
493
+ rep,
494
+ editorInfo,
495
+ line: 0,
496
+ char: 2,
497
+ lineText: "hello",
498
+ };
499
+ commands.normal["dh"](ctx);
500
+
501
+ assert.equal(typeof state.register, "string");
502
+ assert(state.register.length > 0);
503
+ });
504
+
505
+ it("yy with count yanks multiple lines", () => {
506
+ const rep = makeRep(["line1", "line2", "line3"]);
507
+ const { editorInfo } = makeMockEditorInfo();
508
+
509
+ const ctx = {
510
+ rep,
511
+ editorInfo,
512
+ line: 0,
513
+ char: 0,
514
+ lineText: "line1",
515
+ count: 2,
516
+ };
517
+ commands.normal["yy"](ctx);
518
+
519
+ assert.equal(state.register.length, 2);
520
+ assert.deepEqual(state.register, ["line1", "line2"]);
521
+ });
522
+
523
+ it("dd with count deletes multiple lines", () => {
524
+ const rep = makeRep(["line1", "line2", "line3", "line4"]);
525
+ const { editorInfo } = makeMockEditorInfo();
526
+
527
+ const ctx = {
528
+ rep,
529
+ editorInfo,
530
+ line: 0,
531
+ char: 0,
532
+ lineText: "line1",
533
+ count: 2,
534
+ };
535
+ commands.normal["dd"](ctx);
536
+
537
+ assert.equal(state.register.length, 2);
538
+ assert.deepEqual(state.register, ["line1", "line2"]);
539
+ });
540
+
541
+ it("w motion moves to next word", () => {
542
+ const rep = makeRep(["hello world"]);
543
+ const { editorInfo, calls } = makeMockEditorInfo();
544
+
545
+ const ctx = {
546
+ rep,
547
+ editorInfo,
548
+ line: 0,
549
+ char: 0,
550
+ lineText: "hello world",
551
+ count: 1,
552
+ };
553
+ commands.normal["w"](ctx);
554
+
555
+ // w should move to position 6 (start of "world")
556
+ assert(calls.length > 0, "w should generate a motion call");
557
+ const moveCall = calls[calls.length - 1];
558
+ assert.equal(moveCall.start[1], 6, "w should move to position 6");
559
+ });
560
+
561
+ it("dw deletes from cursor to start of next word", () => {
562
+ const rep = makeRep(["hello world more"]);
563
+ const { editorInfo, calls } = makeMockEditorInfo();
564
+
565
+ const ctx = {
566
+ rep,
567
+ editorInfo,
568
+ line: 0,
569
+ char: 0,
570
+ lineText: "hello world more",
571
+ };
572
+ commands.normal["dw"](ctx);
573
+
574
+ // dw should generate replace call(s) for deletion
575
+ const replaceCalls = calls.filter((c) => c.type === "replace");
576
+ assert(replaceCalls.length > 0, "dw should perform deletion");
577
+ });
578
+
579
+ it("ye yanks to end of word", () => {
580
+ const rep = makeRep(["hello world"]);
581
+ const { editorInfo } = makeMockEditorInfo();
582
+
583
+ const ctx = {
584
+ rep,
585
+ editorInfo,
586
+ line: 0,
587
+ char: 0,
588
+ lineText: "hello world",
589
+ };
590
+ commands.normal["ye"](ctx);
591
+
592
+ assert.equal(typeof state.register, "string");
593
+ });
594
+
595
+ it("cl changes one character", () => {
596
+ const rep = makeRep(["hello"]);
597
+ const { editorInfo } = makeMockEditorInfo();
598
+
599
+ const ctx = {
600
+ rep,
601
+ editorInfo,
602
+ line: 0,
603
+ char: 1,
604
+ lineText: "hello",
605
+ };
606
+ commands.normal["cl"](ctx);
607
+
608
+ assert.equal(state.mode, "insert");
609
+ });
610
+ });
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Edge-case tests for vim motions and commands
614
+
615
+ describe("edge cases: cc with count", () => {
616
+ beforeEach(resetState);
617
+
618
+ it("2cc should delete extra lines and clear remaining line", () => {
619
+ const rep = makeRep(["aaa", "bbb", "ccc"]);
620
+ const { editorInfo, calls } = makeMockEditorInfo();
621
+
622
+ const ctx = {
623
+ rep,
624
+ editorInfo,
625
+ line: 0,
626
+ char: 0,
627
+ lineText: "aaa",
628
+ count: 2,
629
+ };
630
+ commands.normal["cc"](ctx);
631
+
632
+ assert.equal(state.mode, "insert");
633
+
634
+ const replaces = calls.filter((c) => c.type === "replace");
635
+ const deletesLine = replaces.some(
636
+ (r) =>
637
+ r.start[0] !== r.end[0] ||
638
+ (r.start[0] === r.end[0] && r.end[1] === 0 && r.start[1] === 0),
639
+ );
640
+ assert.ok(
641
+ deletesLine || replaces.length === 1,
642
+ "2cc should delete extra lines, not just clear text on each line separately",
643
+ );
644
+ });
645
+ });
646
+
647
+ describe("edge cases: dd edge cases", () => {
648
+ beforeEach(resetState);
649
+
650
+ it("dd on single-line document leaves empty line", () => {
651
+ const rep = makeRep(["hello"]);
652
+ const { editorInfo, calls } = makeMockEditorInfo();
653
+
654
+ const ctx = {
655
+ rep,
656
+ editorInfo,
657
+ line: 0,
658
+ char: 0,
659
+ lineText: "hello",
660
+ count: 1,
661
+ };
662
+ commands.normal["dd"](ctx);
663
+
664
+ const replaces = calls.filter((c) => c.type === "replace");
665
+ assert.ok(replaces.length > 0);
666
+ assert.equal(
667
+ replaces[0].newText,
668
+ "",
669
+ "dd on single line should clear content",
670
+ );
671
+ assert.deepEqual(replaces[0].start, [0, 0]);
672
+ assert.deepEqual(replaces[0].end, [0, 5]);
673
+ });
674
+
675
+ it("3dd with only 2 lines remaining deletes to end", () => {
676
+ const rep = makeRep(["aaa", "bbb"]);
677
+ const { editorInfo, calls } = makeMockEditorInfo();
678
+
679
+ const ctx = {
680
+ rep,
681
+ editorInfo,
682
+ line: 0,
683
+ char: 0,
684
+ lineText: "aaa",
685
+ count: 3,
686
+ };
687
+ commands.normal["dd"](ctx);
688
+
689
+ assert.ok(Array.isArray(state.register), "dd should yank lines as array");
690
+ assert.equal(
691
+ state.register.length,
692
+ 2,
693
+ "3dd on 2-line doc should yank both lines",
694
+ );
695
+ });
696
+ });
697
+
698
+ describe("edge cases: J (join) edge cases", () => {
699
+ beforeEach(resetState);
700
+
701
+ it("J on last line does nothing", () => {
702
+ const rep = makeRep(["only line"]);
703
+ const { editorInfo, calls } = makeMockEditorInfo();
704
+
705
+ const ctx = {
706
+ rep,
707
+ editorInfo,
708
+ line: 0,
709
+ char: 0,
710
+ lineText: "only line",
711
+ count: 1,
712
+ };
713
+ commands.normal["J"](ctx);
714
+
715
+ const replaces = calls.filter((c) => c.type === "replace");
716
+ assert.equal(replaces.length, 0, "J on last line should not join");
717
+ });
718
+
719
+ it("J trims leading whitespace from joined line", () => {
720
+ const rep = makeRep(["hello", " world"]);
721
+ const { editorInfo, calls } = makeMockEditorInfo();
722
+
723
+ const ctx = {
724
+ rep,
725
+ editorInfo,
726
+ line: 0,
727
+ char: 0,
728
+ lineText: "hello",
729
+ count: 1,
730
+ };
731
+ commands.normal["J"](ctx);
732
+
733
+ const replaces = calls.filter((c) => c.type === "replace");
734
+ assert.ok(replaces.length > 0);
735
+ assert.equal(
736
+ replaces[0].newText,
737
+ " world",
738
+ "J should trim leading whitespace and add single space",
739
+ );
740
+ });
741
+ });
742
+
743
+ describe("edge cases: count handling", () => {
744
+ beforeEach(resetState);
745
+
746
+ it("count 0 after digits is part of count (e.g., 10j)", () => {
747
+ const rep = makeRep(Array.from({ length: 20 }, (_, i) => `line${i}`));
748
+ const { editorInfo, calls } = makeMockEditorInfo();
749
+
750
+ const baseCtx = {
751
+ rep,
752
+ editorInfo,
753
+ line: 0,
754
+ char: 0,
755
+ lineText: "line0",
756
+ };
757
+
758
+ handleKey("1", baseCtx);
759
+ handleKey("0", baseCtx);
760
+ handleKey("j", baseCtx);
761
+
762
+ const selects = calls.filter((c) => c.type === "select");
763
+ assert.ok(selects.length > 0);
764
+ assert.equal(
765
+ selects[selects.length - 1].start[0],
766
+ 10,
767
+ "10j should go to line 10",
768
+ );
769
+ });
770
+
771
+ it("0 without prior digits is motion to column 0", () => {
772
+ const rep = makeRep(["hello world"]);
773
+ const { editorInfo, calls } = makeMockEditorInfo();
774
+
775
+ const ctx = {
776
+ rep,
777
+ editorInfo,
778
+ line: 0,
779
+ char: 5,
780
+ lineText: "hello world",
781
+ };
782
+
783
+ handleKey("0", ctx);
784
+
785
+ const selects = calls.filter((c) => c.type === "select");
786
+ assert.ok(selects.length > 0);
787
+ assert.deepEqual(selects[0].start, [0, 0]);
788
+ });
789
+ });
790
+
791
+ describe("edge cases: dw at end of line", () => {
792
+ beforeEach(resetState);
793
+
794
+ it("dw at last word deletes to end of line", () => {
795
+ const rep = makeRep(["hello world"]);
796
+ const { editorInfo, calls } = makeMockEditorInfo();
797
+
798
+ const ctx = {
799
+ rep,
800
+ editorInfo,
801
+ line: 0,
802
+ char: 6,
803
+ lineText: "hello world",
804
+ count: 1,
805
+ };
806
+ commands.normal["dw"](ctx);
807
+
808
+ const replaces = calls.filter((c) => c.type === "replace");
809
+ assert.ok(replaces.length > 0);
810
+ const deleted = ctx.lineText.slice(
811
+ replaces[0].start[1],
812
+ replaces[0].end[1],
813
+ );
814
+ assert.equal(
815
+ deleted,
816
+ "world",
817
+ "dw at last word should delete to end of line",
818
+ );
819
+ });
820
+ });
821
+
822
+ describe("edge cases: d$ and D", () => {
823
+ beforeEach(resetState);
824
+
825
+ it("d$ at last char deletes that character", () => {
826
+ const rep = makeRep(["abc"]);
827
+ const { editorInfo, calls } = makeMockEditorInfo();
828
+
829
+ const ctx = {
830
+ rep,
831
+ editorInfo,
832
+ line: 0,
833
+ char: 2,
834
+ lineText: "abc",
835
+ count: 1,
836
+ };
837
+ commands.normal["d$"](ctx);
838
+
839
+ const replaces = calls.filter((c) => c.type === "replace");
840
+ assert.ok(replaces.length > 0);
841
+ assert.deepEqual(replaces[0].start, [0, 2]);
842
+ assert.deepEqual(
843
+ replaces[0].end,
844
+ [0, 3],
845
+ "d$ at last char should delete it (inclusive)",
846
+ );
847
+ });
848
+
849
+ it("D at column 0 deletes entire line content", () => {
850
+ const rep = makeRep(["hello"]);
851
+ const { editorInfo, calls } = makeMockEditorInfo();
852
+
853
+ const ctx = {
854
+ rep,
855
+ editorInfo,
856
+ line: 0,
857
+ char: 0,
858
+ lineText: "hello",
859
+ count: 1,
860
+ };
861
+ commands.normal["D"](ctx);
862
+
863
+ assert.equal(state.register, "hello");
864
+ const replaces = calls.filter((c) => c.type === "replace");
865
+ assert.deepEqual(replaces[0].start, [0, 0]);
866
+ assert.deepEqual(replaces[0].end, [0, 5]);
867
+ });
868
+ });
869
+
870
+ describe("edge cases: de vs dw", () => {
871
+ beforeEach(resetState);
872
+
873
+ it("de deletes to end of current word (inclusive)", () => {
874
+ const rep = makeRep(["hello world"]);
875
+ const { editorInfo, calls } = makeMockEditorInfo();
876
+
877
+ const ctx = {
878
+ rep,
879
+ editorInfo,
880
+ line: 0,
881
+ char: 0,
882
+ lineText: "hello world",
883
+ count: 1,
884
+ };
885
+ commands.normal["de"](ctx);
886
+
887
+ const replaces = calls.filter((c) => c.type === "replace");
888
+ assert.ok(replaces.length > 0);
889
+ assert.deepEqual(replaces[0].start, [0, 0]);
890
+ assert.deepEqual(
891
+ replaces[0].end,
892
+ [0, 5],
893
+ "de should delete 'hello' (inclusive of last char)",
894
+ );
895
+ });
896
+
897
+ it("dw at start of word deletes word and trailing space", () => {
898
+ const rep = makeRep(["hello world"]);
899
+ const { editorInfo, calls } = makeMockEditorInfo();
900
+
901
+ const ctx = {
902
+ rep,
903
+ editorInfo,
904
+ line: 0,
905
+ char: 0,
906
+ lineText: "hello world",
907
+ count: 1,
908
+ };
909
+ commands.normal["dw"](ctx);
910
+
911
+ const replaces = calls.filter((c) => c.type === "replace");
912
+ assert.ok(replaces.length > 0);
913
+ assert.deepEqual(replaces[0].start, [0, 0]);
914
+ assert.deepEqual(
915
+ replaces[0].end,
916
+ [0, 6],
917
+ "dw should delete 'hello ' (word + trailing space)",
918
+ );
919
+ });
920
+ });
921
+
922
+ describe("edge cases: s with count", () => {
923
+ beforeEach(resetState);
924
+
925
+ it("3s at position 1 deletes 3 chars and enters insert", () => {
926
+ const rep = makeRep(["abcdef"]);
927
+ const { editorInfo, calls } = makeMockEditorInfo();
928
+
929
+ const ctx = {
930
+ rep,
931
+ editorInfo,
932
+ line: 0,
933
+ char: 1,
934
+ lineText: "abcdef",
935
+ count: 3,
936
+ };
937
+ commands.normal["s"](ctx);
938
+
939
+ assert.equal(state.mode, "insert");
940
+ const replaces = calls.filter((c) => c.type === "replace");
941
+ assert.ok(replaces.length > 0);
942
+ assert.deepEqual(replaces[0].start, [0, 1]);
943
+ assert.deepEqual(replaces[0].end, [0, 4], "3s should delete 3 chars");
944
+ });
945
+
946
+ it("s with count exceeding line length clamps", () => {
947
+ const rep = makeRep(["ab"]);
948
+ const { editorInfo, calls } = makeMockEditorInfo();
949
+
950
+ const ctx = {
951
+ rep,
952
+ editorInfo,
953
+ line: 0,
954
+ char: 0,
955
+ lineText: "ab",
956
+ count: 10,
957
+ };
958
+ commands.normal["s"](ctx);
959
+
960
+ assert.equal(state.mode, "insert");
961
+ const replaces = calls.filter((c) => c.type === "replace");
962
+ assert.deepEqual(
963
+ replaces[0].end,
964
+ [0, 2],
965
+ "s with large count should clamp to line length",
966
+ );
967
+ });
968
+ });
969
+
970
+ describe("edge cases: x at end of line", () => {
971
+ beforeEach(resetState);
972
+
973
+ it("x at last character should delete it", () => {
974
+ const rep = makeRep(["abc"]);
975
+ const { editorInfo, calls } = makeMockEditorInfo();
976
+
977
+ const ctx = {
978
+ rep,
979
+ editorInfo,
980
+ line: 0,
981
+ char: 2,
982
+ lineText: "abc",
983
+ count: 1,
984
+ };
985
+ commands.normal["x"](ctx);
986
+
987
+ const replaces = calls.filter((c) => c.type === "replace");
988
+ assert.ok(replaces.length > 0);
989
+ assert.equal(state.register, "c");
990
+ });
991
+
992
+ it("3x with only 2 chars remaining deletes what's available", () => {
993
+ const rep = makeRep(["abcd"]);
994
+ const { editorInfo, calls } = makeMockEditorInfo();
995
+
996
+ const ctx = {
997
+ rep,
998
+ editorInfo,
999
+ line: 0,
1000
+ char: 2,
1001
+ lineText: "abcd",
1002
+ count: 3,
1003
+ };
1004
+ commands.normal["x"](ctx);
1005
+
1006
+ assert.equal(state.register, "cd", "3x with 2 remaining should delete 2");
1007
+ });
1008
+ });
1009
+
1010
+ describe("edge cases: df and dt (operator + char motion)", () => {
1011
+ beforeEach(resetState);
1012
+
1013
+ it("df deletes up to and including target char", () => {
1014
+ const rep = makeRep(["hello world"]);
1015
+ const { editorInfo, calls } = makeMockEditorInfo();
1016
+
1017
+ const ctx = {
1018
+ rep,
1019
+ editorInfo,
1020
+ line: 0,
1021
+ char: 0,
1022
+ lineText: "hello world",
1023
+ count: 1,
1024
+ };
1025
+ parameterized["df"]("o", ctx);
1026
+
1027
+ const replaces = calls.filter((c) => c.type === "replace");
1028
+ assert.ok(replaces.length > 0);
1029
+ assert.deepEqual(replaces[0].start, [0, 0]);
1030
+ assert.deepEqual(
1031
+ replaces[0].end,
1032
+ [0, 5],
1033
+ "df o should delete 'hello' (inclusive of 'o' at position 4)",
1034
+ );
1035
+ });
1036
+
1037
+ it("dt deletes up to but not including target char", () => {
1038
+ const rep = makeRep(["hello world"]);
1039
+ const { editorInfo, calls } = makeMockEditorInfo();
1040
+
1041
+ const ctx = {
1042
+ rep,
1043
+ editorInfo,
1044
+ line: 0,
1045
+ char: 0,
1046
+ lineText: "hello world",
1047
+ count: 1,
1048
+ };
1049
+ parameterized["dt"]("o", ctx);
1050
+
1051
+ const replaces = calls.filter((c) => c.type === "replace");
1052
+ assert.ok(replaces.length > 0);
1053
+ assert.deepEqual(replaces[0].start, [0, 0]);
1054
+ assert.deepEqual(
1055
+ replaces[0].end,
1056
+ [0, 4],
1057
+ "dt o should delete 'hell' (up to but not including 'o')",
1058
+ );
1059
+ });
1060
+
1061
+ it("dF deletes backward including target", () => {
1062
+ const rep = makeRep(["hello world"]);
1063
+ const { editorInfo, calls } = makeMockEditorInfo();
1064
+
1065
+ const ctx = {
1066
+ rep,
1067
+ editorInfo,
1068
+ line: 0,
1069
+ char: 7,
1070
+ lineText: "hello world",
1071
+ count: 1,
1072
+ };
1073
+ parameterized["dF"]("o", ctx);
1074
+
1075
+ const replaces = calls.filter((c) => c.type === "replace");
1076
+ assert.ok(replaces.length > 0);
1077
+ assert.deepEqual(
1078
+ replaces[0].start,
1079
+ [0, 4],
1080
+ "dF o from pos 7 should start at 'o' (pos 4)",
1081
+ );
1082
+ assert.deepEqual(
1083
+ replaces[0].end,
1084
+ [0, 8],
1085
+ "dF o from pos 7 should delete up to and including cursor pos",
1086
+ );
1087
+ });
1088
+
1089
+ it("dT deletes backward not including target", () => {
1090
+ const rep = makeRep(["hello world"]);
1091
+ const { editorInfo, calls } = makeMockEditorInfo();
1092
+
1093
+ const ctx = {
1094
+ rep,
1095
+ editorInfo,
1096
+ line: 0,
1097
+ char: 7,
1098
+ lineText: "hello world",
1099
+ count: 1,
1100
+ };
1101
+ parameterized["dT"]("o", ctx);
1102
+
1103
+ const replaces = calls.filter((c) => c.type === "replace");
1104
+ assert.ok(replaces.length > 0);
1105
+ assert.deepEqual(
1106
+ replaces[0].start,
1107
+ [0, 5],
1108
+ "dT o from pos 7 should start after 'o' (pos 5)",
1109
+ );
1110
+ assert.deepEqual(
1111
+ replaces[0].end,
1112
+ [0, 7],
1113
+ "dT o from pos 7 should end before cursor",
1114
+ );
1115
+ });
1116
+ });
1117
+
1118
+ // --- Register bugs ---