ruvim 0.1.0

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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +15 -0
  3. data/README.md +135 -0
  4. data/Rakefile +36 -0
  5. data/docs/binding.md +125 -0
  6. data/docs/command.md +306 -0
  7. data/docs/config.md +155 -0
  8. data/docs/done.md +112 -0
  9. data/docs/plugin.md +559 -0
  10. data/docs/spec.md +655 -0
  11. data/docs/todo.md +63 -0
  12. data/docs/tutorial.md +490 -0
  13. data/docs/vim_diff.md +179 -0
  14. data/exe/ruvim +6 -0
  15. data/lib/ruvim/app.rb +1600 -0
  16. data/lib/ruvim/buffer.rb +421 -0
  17. data/lib/ruvim/cli.rb +264 -0
  18. data/lib/ruvim/clipboard.rb +73 -0
  19. data/lib/ruvim/command_invocation.rb +14 -0
  20. data/lib/ruvim/command_line.rb +63 -0
  21. data/lib/ruvim/command_registry.rb +38 -0
  22. data/lib/ruvim/config_dsl.rb +134 -0
  23. data/lib/ruvim/config_loader.rb +68 -0
  24. data/lib/ruvim/context.rb +26 -0
  25. data/lib/ruvim/dispatcher.rb +120 -0
  26. data/lib/ruvim/display_width.rb +110 -0
  27. data/lib/ruvim/editor.rb +1025 -0
  28. data/lib/ruvim/ex_command_registry.rb +80 -0
  29. data/lib/ruvim/global_commands.rb +1889 -0
  30. data/lib/ruvim/highlighter.rb +52 -0
  31. data/lib/ruvim/input.rb +66 -0
  32. data/lib/ruvim/keymap_manager.rb +96 -0
  33. data/lib/ruvim/screen.rb +452 -0
  34. data/lib/ruvim/terminal.rb +30 -0
  35. data/lib/ruvim/text_metrics.rb +96 -0
  36. data/lib/ruvim/version.rb +5 -0
  37. data/lib/ruvim/window.rb +71 -0
  38. data/lib/ruvim.rb +30 -0
  39. data/sig/ruvim.rbs +4 -0
  40. data/test/app_completion_test.rb +39 -0
  41. data/test/app_dot_repeat_test.rb +54 -0
  42. data/test/app_motion_test.rb +73 -0
  43. data/test/app_register_test.rb +47 -0
  44. data/test/app_scenario_test.rb +77 -0
  45. data/test/app_startup_test.rb +199 -0
  46. data/test/app_text_object_test.rb +54 -0
  47. data/test/app_unicode_behavior_test.rb +66 -0
  48. data/test/buffer_test.rb +72 -0
  49. data/test/cli_test.rb +165 -0
  50. data/test/config_dsl_test.rb +78 -0
  51. data/test/dispatcher_test.rb +124 -0
  52. data/test/editor_mark_test.rb +69 -0
  53. data/test/editor_register_test.rb +64 -0
  54. data/test/fixtures/render_basic_snapshot.txt +8 -0
  55. data/test/fixtures/render_basic_snapshot_nonumber.txt +8 -0
  56. data/test/fixtures/render_unicode_scrolled_snapshot.txt +7 -0
  57. data/test/highlighter_test.rb +16 -0
  58. data/test/input_screen_integration_test.rb +69 -0
  59. data/test/keymap_manager_test.rb +48 -0
  60. data/test/render_snapshot_test.rb +70 -0
  61. data/test/screen_test.rb +123 -0
  62. data/test/search_option_test.rb +39 -0
  63. data/test/test_helper.rb +15 -0
  64. data/test/text_metrics_test.rb +42 -0
  65. data/test/window_test.rb +21 -0
  66. metadata +106 -0
@@ -0,0 +1,1025 @@
1
+ module RuVim
2
+ class Editor
3
+ OPTION_DEFS = {
4
+ "number" => { default_scope: :window, type: :bool, default: false },
5
+ "relativenumber" => { default_scope: :window, type: :bool, default: false },
6
+ "ignorecase" => { default_scope: :global, type: :bool, default: false },
7
+ "smartcase" => { default_scope: :global, type: :bool, default: false },
8
+ "hlsearch" => { default_scope: :global, type: :bool, default: true },
9
+ "tabstop" => { default_scope: :buffer, type: :int, default: 2 },
10
+ "filetype" => { default_scope: :buffer, type: :string, default: nil }
11
+ }.freeze
12
+
13
+ attr_reader :buffers, :windows
14
+ attr_accessor :current_window_id, :mode, :message, :pending_count, :alternate_buffer_id, :window_layout, :restricted_mode
15
+
16
+ def initialize
17
+ @buffers = {}
18
+ @windows = {}
19
+ @window_order = []
20
+ @tabpages = []
21
+ @current_tabpage_index = nil
22
+ @next_tabpage_id = 1
23
+ @suspend_tab_autosave = false
24
+ @next_buffer_id = 1
25
+ @next_window_id = 1
26
+ @current_window_id = nil
27
+ @alternate_buffer_id = nil
28
+ @mode = :normal
29
+ @window_layout = :single
30
+ @message = ""
31
+ @message_kind = :info
32
+ @pending_count = nil
33
+ @restricted_mode = false
34
+ @running = true
35
+ @global_options = default_global_options
36
+ @command_line = CommandLine.new
37
+ @last_search = nil
38
+ @last_find = nil
39
+ @registers = {}
40
+ @active_register_name = nil
41
+ @local_marks = Hash.new { |h, k| h[k] = {} }
42
+ @global_marks = {}
43
+ @jumplist = []
44
+ @jump_index = nil
45
+ @macros = {}
46
+ @macro_recording = nil
47
+ @visual_state = nil
48
+ @quickfix_list = { items: [], index: nil }
49
+ @location_lists = Hash.new { |h, k| h[k] = { items: [], index: nil } }
50
+ end
51
+
52
+ def running?
53
+ @running
54
+ end
55
+
56
+ def restricted_mode?
57
+ !!@restricted_mode
58
+ end
59
+
60
+ def request_quit!
61
+ @running = false
62
+ end
63
+
64
+ def command_line
65
+ @command_line
66
+ end
67
+
68
+ def global_options
69
+ @global_options
70
+ end
71
+
72
+ def command_line_prefix
73
+ @command_line.prefix
74
+ end
75
+
76
+ def last_search
77
+ @last_search
78
+ end
79
+
80
+ def last_find
81
+ @last_find
82
+ end
83
+
84
+ def set_last_search(pattern:, direction:)
85
+ @last_search = { pattern: pattern.to_s, direction: direction.to_sym }
86
+ end
87
+
88
+ def set_last_find(char:, direction:, till:)
89
+ @last_find = { char: char.to_s, direction: direction.to_sym, till: !!till }
90
+ end
91
+
92
+ def current_window
93
+ @windows.fetch(@current_window_id)
94
+ end
95
+
96
+ def current_buffer
97
+ @buffers.fetch(current_window.buffer_id)
98
+ end
99
+
100
+ def option_def(name)
101
+ OPTION_DEFS[name.to_s]
102
+ end
103
+
104
+ def option_default_scope(name)
105
+ option_def(name)&.fetch(:default_scope, :global) || :global
106
+ end
107
+
108
+ def effective_option(name, window: current_window, buffer: current_buffer)
109
+ key = name.to_s
110
+ if window && window.options.key?(key)
111
+ window.options[key]
112
+ elsif buffer && buffer.options.key?(key)
113
+ buffer.options[key]
114
+ else
115
+ @global_options[key]
116
+ end
117
+ end
118
+
119
+ def get_option(name, scope: :effective, window: current_window, buffer: current_buffer)
120
+ key = name.to_s
121
+ case scope.to_sym
122
+ when :global
123
+ @global_options[key]
124
+ when :buffer
125
+ buffer&.options&.[](key)
126
+ when :window
127
+ window&.options&.[](key)
128
+ else
129
+ effective_option(key, window:, buffer:)
130
+ end
131
+ end
132
+
133
+ def set_option(name, value, scope: :auto, window: current_window, buffer: current_buffer)
134
+ key = name.to_s
135
+ value = coerce_option_value(key, value)
136
+ actual_scope = (scope.to_sym == :auto ? option_default_scope(key) : scope.to_sym)
137
+ case actual_scope
138
+ when :global
139
+ @global_options[key] = value
140
+ when :buffer
141
+ raise RuVim::CommandError, "No current buffer" unless buffer
142
+ buffer.options[key] = value
143
+ when :window
144
+ raise RuVim::CommandError, "No current window" unless window
145
+ window.options[key] = value
146
+ else
147
+ raise RuVim::CommandError, "Unknown option scope: #{actual_scope}"
148
+ end
149
+ value
150
+ end
151
+
152
+ def option_snapshot(window: current_window, buffer: current_buffer)
153
+ keys = (OPTION_DEFS.keys + @global_options.keys + (buffer&.options&.keys || []) + (window&.options&.keys || [])).uniq.sort
154
+ keys.map do |k|
155
+ {
156
+ name: k,
157
+ effective: get_option(k, scope: :effective, window:, buffer:),
158
+ global: get_option(k, scope: :global, window:, buffer:),
159
+ buffer: get_option(k, scope: :buffer, window:, buffer:),
160
+ window: get_option(k, scope: :window, window:, buffer:)
161
+ }
162
+ end
163
+ end
164
+
165
+ def detect_filetype(path)
166
+ p = path.to_s
167
+ return nil if p.empty?
168
+
169
+ base = File.basename(p)
170
+ return "ruby" if %w[Gemfile Rakefile Guardfile].include?(base)
171
+
172
+ {
173
+ ".rb" => "ruby",
174
+ ".rake" => "ruby",
175
+ ".ru" => "ruby",
176
+ ".py" => "python",
177
+ ".js" => "javascript",
178
+ ".mjs" => "javascript",
179
+ ".cjs" => "javascript",
180
+ ".ts" => "typescript",
181
+ ".tsx" => "typescriptreact",
182
+ ".jsx" => "javascriptreact",
183
+ ".json" => "json",
184
+ ".yml" => "yaml",
185
+ ".yaml" => "yaml",
186
+ ".md" => "markdown",
187
+ ".txt" => "text",
188
+ ".html" => "html",
189
+ ".css" => "css",
190
+ ".sh" => "sh"
191
+ }[File.extname(base).downcase]
192
+ end
193
+
194
+ def registers
195
+ @registers
196
+ end
197
+
198
+ def set_register(name = "\"", text:, type: :charwise)
199
+ key = name.to_s
200
+ return { text: text.to_s, type: type.to_sym } if key == "_"
201
+
202
+ payload = write_register_payload(key, text: text.to_s, type: type.to_sym)
203
+ write_clipboard_register(key, payload)
204
+ @registers["\""] = payload unless key == "\""
205
+ payload
206
+ end
207
+
208
+ def store_operator_register(name = "\"", text:, type:, kind:)
209
+ key = (name || "\"").to_s
210
+ payload = { text: text.to_s, type: type.to_sym }
211
+ return payload if key == "_"
212
+
213
+ written = set_register(key, text: payload[:text], type: payload[:type])
214
+ op_payload = dup_register_payload(written)
215
+
216
+ case kind.to_sym
217
+ when :yank
218
+ @registers["0"] = dup_register_payload(op_payload)
219
+ when :delete, :change
220
+ rotate_delete_registers!(op_payload)
221
+ end
222
+
223
+ written
224
+ end
225
+
226
+ def get_register(name = "\"")
227
+ key = name.to_s.downcase
228
+ return read_clipboard_register(key) if clipboard_register?(key)
229
+
230
+ @registers[key]
231
+ end
232
+
233
+ def set_active_register(name)
234
+ @active_register_name = name.to_s
235
+ end
236
+
237
+ def active_register_name
238
+ @active_register_name
239
+ end
240
+
241
+ def consume_active_register(default = "\"")
242
+ name = @active_register_name || default
243
+ @active_register_name = nil
244
+ name
245
+ end
246
+
247
+ def visual_state
248
+ @visual_state
249
+ end
250
+
251
+ def macros
252
+ @macros
253
+ end
254
+
255
+ def macro_recording?
256
+ !@macro_recording.nil?
257
+ end
258
+
259
+ def macro_recording_name
260
+ @macro_recording && @macro_recording[:name]
261
+ end
262
+
263
+ def start_macro_recording(name)
264
+ reg = name.to_s
265
+ return false unless reg.match?(/\A[A-Za-z0-9]\z/)
266
+
267
+ @macro_recording = { name: reg, keys: [] }
268
+ true
269
+ end
270
+
271
+ def stop_macro_recording
272
+ rec = @macro_recording
273
+ @macro_recording = nil
274
+ return nil unless rec
275
+
276
+ name = rec[:name]
277
+ keys = rec[:keys]
278
+ if name.match?(/\A[A-Z]\z/)
279
+ base = name.downcase
280
+ @macros[base] = [*(@macros[base] || []), *keys]
281
+ @macros[base]
282
+ else
283
+ @macros[name.downcase] = keys
284
+ end
285
+ end
286
+
287
+ def record_macro_key(key)
288
+ return unless @macro_recording
289
+
290
+ @macro_recording[:keys] << dup_macro_key(key)
291
+ end
292
+
293
+ def macro_keys(name)
294
+ @macros[name.to_s.downcase]
295
+ end
296
+
297
+ def current_location
298
+ { buffer_id: current_buffer.id, row: current_window.cursor_y, col: current_window.cursor_x }
299
+ end
300
+
301
+ def set_mark(name, window = current_window)
302
+ mark = name.to_s
303
+ return false unless mark.match?(/\A[A-Za-z]\z/)
304
+
305
+ loc = { buffer_id: window.buffer_id, row: window.cursor_y, col: window.cursor_x }
306
+ if mark.match?(/\A[a-z]\z/)
307
+ @local_marks[window.buffer_id][mark] = loc
308
+ else
309
+ @global_marks[mark] = loc
310
+ end
311
+ true
312
+ end
313
+
314
+ def mark_location(name, buffer_id: current_buffer.id)
315
+ mark = name.to_s
316
+ return nil unless mark.match?(/\A[A-Za-z]\z/)
317
+
318
+ if mark.match?(/\A[a-z]\z/)
319
+ @local_marks[buffer_id][mark]
320
+ else
321
+ @global_marks[mark]
322
+ end
323
+ end
324
+
325
+ def push_jump_location(location = current_location)
326
+ loc = normalize_location(location)
327
+ return nil unless loc
328
+
329
+ if @jump_index && @jump_index < @jumplist.length - 1
330
+ @jumplist = @jumplist[0..@jump_index]
331
+ end
332
+ @jumplist << loc unless same_location?(@jumplist.last, loc)
333
+ @jump_index = @jumplist.length - 1 unless @jumplist.empty?
334
+ loc
335
+ end
336
+
337
+ def jump_older(linewise: false)
338
+ return nil if @jumplist.empty?
339
+
340
+ if @jump_index.nil?
341
+ push_jump_location(current_location)
342
+ else
343
+ @jump_index = [@jump_index - 1, 0].max
344
+ end
345
+ jump_to_location(@jumplist[@jump_index], linewise:)
346
+ end
347
+
348
+ def jump_newer(linewise: false)
349
+ return nil if @jumplist.empty? || @jump_index.nil?
350
+
351
+ next_idx = @jump_index + 1
352
+ return nil if next_idx >= @jumplist.length
353
+
354
+ @jump_index = next_idx
355
+ jump_to_location(@jumplist[@jump_index], linewise:)
356
+ end
357
+
358
+ def jump_to_mark(name, linewise: false)
359
+ loc = mark_location(name)
360
+ return nil unless loc
361
+
362
+ jump_to_location(loc, linewise:)
363
+ end
364
+
365
+ def visual_active?
366
+ !@visual_state.nil?
367
+ end
368
+
369
+ def enter_visual(mode)
370
+ @mode = mode.to_sym
371
+ @visual_state = {
372
+ mode: mode.to_sym,
373
+ anchor_y: current_window.cursor_y,
374
+ anchor_x: current_window.cursor_x
375
+ }
376
+ @pending_count = nil
377
+ end
378
+
379
+ def clear_visual
380
+ @visual_state = nil
381
+ end
382
+
383
+ def visual_selection(window = current_window)
384
+ return nil unless @visual_state
385
+
386
+ ay = @visual_state[:anchor_y]
387
+ ax = @visual_state[:anchor_x]
388
+ cy = window.cursor_y
389
+ cx = window.cursor_x
390
+ case @visual_state[:mode]
391
+ when :visual_line
392
+ start_row, end_row = [ay, cy].minmax
393
+ {
394
+ mode: :linewise,
395
+ start_row: start_row,
396
+ start_col: 0,
397
+ end_row: end_row,
398
+ end_col: current_buffer.line_length(end_row)
399
+ }
400
+ when :visual_block
401
+ start_row, end_row = [ay, cy].minmax
402
+ start_col, end_col = [ax, cx].minmax
403
+ {
404
+ mode: :blockwise,
405
+ start_row: start_row,
406
+ start_col: start_col,
407
+ end_row: end_row,
408
+ end_col: end_col + 1
409
+ }
410
+ else
411
+ if ay < cy || (ay == cy && ax <= cx)
412
+ s_row, s_col, e_row, e_col = [ay, ax, cy, cx]
413
+ else
414
+ s_row, s_col, e_row, e_col = [cy, cx, ay, ax]
415
+ end
416
+ {
417
+ mode: :charwise,
418
+ start_row: s_row,
419
+ start_col: s_col,
420
+ end_row: e_row,
421
+ end_col: e_col + 1
422
+ }
423
+ end
424
+ end
425
+
426
+ def add_empty_buffer(path: nil)
427
+ id = next_buffer_id
428
+ buffer = Buffer.new(id:, path:)
429
+ assign_detected_filetype(buffer)
430
+ @buffers[id] = buffer
431
+ buffer
432
+ end
433
+
434
+ def add_virtual_buffer(kind:, name:, lines:, filetype: nil, readonly: true, modifiable: false)
435
+ id = next_buffer_id
436
+ buffer = Buffer.new(id:, lines:, kind:, name:, readonly:, modifiable:)
437
+ buffer.options["filetype"] = filetype if filetype
438
+ @buffers[id] = buffer
439
+ buffer
440
+ end
441
+
442
+ def add_buffer_from_file(path)
443
+ id = next_buffer_id
444
+ buffer = Buffer.from_file(id:, path:)
445
+ assign_detected_filetype(buffer)
446
+ @buffers[id] = buffer
447
+ buffer
448
+ end
449
+
450
+ def add_window(buffer_id:)
451
+ id = next_window_id
452
+ window = Window.new(id:, buffer_id:)
453
+ @windows[id] = window
454
+ @window_order << id
455
+ @current_window_id ||= id
456
+ ensure_initial_tabpage!
457
+ save_current_tabpage_state! unless @suspend_tab_autosave
458
+ window
459
+ end
460
+
461
+ def split_current_window(layout: :horizontal)
462
+ save_current_tabpage_state! unless @suspend_tab_autosave
463
+ src = current_window
464
+ win = add_window(buffer_id: src.buffer_id)
465
+ win.cursor_x = src.cursor_x
466
+ win.cursor_y = src.cursor_y
467
+ win.row_offset = src.row_offset
468
+ win.col_offset = src.col_offset
469
+ @window_layout = layout.to_sym
470
+ @current_window_id = win.id
471
+ save_current_tabpage_state! unless @suspend_tab_autosave
472
+ win
473
+ end
474
+
475
+ def close_current_window
476
+ close_window(@current_window_id)
477
+ end
478
+
479
+ def close_window(id)
480
+ return nil if @window_order.empty?
481
+ return nil if @window_order.length <= 1
482
+ return nil unless @window_order.include?(id)
483
+
484
+ save_current_tabpage_state! unless @suspend_tab_autosave
485
+ idx = @window_order.index(id) || 0
486
+ @windows.delete(id)
487
+ @window_order.delete(id)
488
+ @location_lists.delete(id)
489
+ @current_window_id = @window_order[[idx, @window_order.length - 1].min] if @current_window_id == id
490
+ @current_window_id ||= @window_order.first
491
+ @window_layout = :single if @window_order.length <= 1
492
+ save_current_tabpage_state! unless @suspend_tab_autosave
493
+ current_window
494
+ end
495
+
496
+ def close_current_tabpage
497
+ ensure_initial_tabpage!
498
+ return nil if @tabpages.length <= 1
499
+
500
+ save_current_tabpage_state!
501
+ removed = @tabpages.delete_at(@current_tabpage_index)
502
+ Array(removed && removed[:window_order]).each do |wid|
503
+ @windows.delete(wid)
504
+ @location_lists.delete(wid)
505
+ end
506
+ @current_tabpage_index = [@current_tabpage_index, @tabpages.length - 1].min
507
+ load_tabpage_state!(@tabpages[@current_tabpage_index])
508
+ current_window
509
+ end
510
+
511
+ def focus_window(id)
512
+ return nil unless @windows.key?(id)
513
+
514
+ @current_window_id = id
515
+ save_current_tabpage_state! unless @suspend_tab_autosave
516
+ current_window
517
+ end
518
+
519
+ def focus_next_window
520
+ return current_window if @window_order.length <= 1
521
+
522
+ idx = @window_order.index(@current_window_id) || 0
523
+ focus_window(@window_order[(idx + 1) % @window_order.length])
524
+ end
525
+
526
+ def focus_prev_window
527
+ return current_window if @window_order.length <= 1
528
+
529
+ idx = @window_order.index(@current_window_id) || 0
530
+ focus_window(@window_order[(idx - 1) % @window_order.length])
531
+ end
532
+
533
+ def focus_window_direction(dir)
534
+ return current_window if @window_order.length <= 1
535
+
536
+ case @window_layout
537
+ when :vertical
538
+ if dir == :left
539
+ focus_prev_window
540
+ elsif dir == :right
541
+ focus_next_window
542
+ else
543
+ current_window
544
+ end
545
+ when :horizontal
546
+ if dir == :up
547
+ focus_prev_window
548
+ elsif dir == :down
549
+ focus_next_window
550
+ else
551
+ current_window
552
+ end
553
+ else
554
+ focus_next_window
555
+ end
556
+ end
557
+
558
+ def switch_to_buffer(buffer_id)
559
+ prev_buffer_id = current_window&.buffer_id
560
+ current_window.buffer_id = buffer_id
561
+ current_window.cursor_x = 0
562
+ current_window.cursor_y = 0
563
+ current_window.row_offset = 0
564
+ current_window.col_offset = 0
565
+ if prev_buffer_id && prev_buffer_id != buffer_id
566
+ @alternate_buffer_id = prev_buffer_id
567
+ end
568
+ save_current_tabpage_state! unless @suspend_tab_autosave
569
+ current_window
570
+ end
571
+
572
+ def open_path(path)
573
+ buffer = add_buffer_from_file(path)
574
+ switch_to_buffer(buffer.id)
575
+ echo(path && File.exist?(path) ? "\"#{path}\" #{buffer.line_count}L" : "\"#{path}\" [New File]")
576
+ buffer
577
+ end
578
+
579
+ def ensure_bootstrap_buffer!
580
+ return unless @buffers.empty?
581
+
582
+ buffer = add_empty_buffer
583
+ add_window(buffer_id: buffer.id)
584
+ ensure_initial_tabpage!
585
+ end
586
+
587
+ def show_help_buffer!(title:, lines:, filetype: "help")
588
+ buffer = add_virtual_buffer(
589
+ kind: :help,
590
+ name: title,
591
+ lines: Array(lines),
592
+ filetype: filetype,
593
+ readonly: true,
594
+ modifiable: false
595
+ )
596
+ switch_to_buffer(buffer.id)
597
+ echo(title)
598
+ buffer
599
+ end
600
+
601
+ def show_intro_buffer_if_applicable!
602
+ return unless @buffers.length == 1
603
+ return unless current_buffer.file_buffer?
604
+ return unless current_buffer.path.nil?
605
+ return unless current_buffer.lines == [""]
606
+ return if current_buffer.modified?
607
+
608
+ current_buffer.replace_all_lines!(intro_lines)
609
+ current_buffer.configure_special!(kind: :intro, name: "[Intro]", readonly: true, modifiable: false)
610
+ current_buffer.modified = false
611
+ current_buffer.options["filetype"] = "help"
612
+ current_window.cursor_x = 0
613
+ current_window.cursor_y = 0
614
+ current_window.row_offset = 0
615
+ current_window.col_offset = 0
616
+ clear_message
617
+ current_buffer
618
+ end
619
+
620
+ def materialize_intro_buffer!
621
+ return false unless current_buffer.intro_buffer?
622
+
623
+ current_buffer.become_normal_empty_buffer!
624
+ current_buffer.options["filetype"] = nil
625
+ current_window.cursor_x = 0
626
+ current_window.cursor_y = 0
627
+ current_window.row_offset = 0
628
+ current_window.col_offset = 0
629
+ true
630
+ end
631
+
632
+ def buffer_ids
633
+ @buffers.keys
634
+ end
635
+
636
+ def next_buffer_id_from(current_id, step = 1)
637
+ ids = buffer_ids
638
+ return nil if ids.empty?
639
+
640
+ idx = ids.index(current_id) || 0
641
+ ids[(idx + step) % ids.length]
642
+ end
643
+
644
+ def window_order
645
+ @window_order
646
+ end
647
+
648
+ def tabpages
649
+ @tabpages
650
+ end
651
+
652
+ def current_tabpage_index
653
+ @current_tabpage_index || 0
654
+ end
655
+
656
+ def current_tabpage_number
657
+ current_tabpage_index + 1
658
+ end
659
+
660
+ def tabpage_count
661
+ @tabpages.length
662
+ end
663
+
664
+ def window_count
665
+ @window_order.length
666
+ end
667
+
668
+ def quickfix_items
669
+ @quickfix_list[:items]
670
+ end
671
+
672
+ def quickfix_index
673
+ @quickfix_list[:index]
674
+ end
675
+
676
+ def set_quickfix_list(items)
677
+ ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
678
+ @quickfix_list = { items: ary, index: ary.empty? ? nil : 0 }
679
+ @quickfix_list
680
+ end
681
+
682
+ def current_quickfix_item
683
+ idx = @quickfix_list[:index]
684
+ idx ? @quickfix_list[:items][idx] : nil
685
+ end
686
+
687
+ def move_quickfix(step)
688
+ items = @quickfix_list[:items]
689
+ return nil if items.empty?
690
+
691
+ @quickfix_list[:index] = ((@quickfix_list[:index] || 0) + step.to_i) % items.length
692
+ current_quickfix_item
693
+ end
694
+
695
+ def location_list(window_id = current_window_id)
696
+ @location_lists[window_id]
697
+ end
698
+
699
+ def location_items(window_id = current_window_id)
700
+ location_list(window_id)[:items]
701
+ end
702
+
703
+ def set_location_list(items, window_id: current_window_id)
704
+ ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
705
+ @location_lists[window_id] = { items: ary, index: ary.empty? ? nil : 0 }
706
+ @location_lists[window_id]
707
+ end
708
+
709
+ def current_location_list_item(window_id = current_window_id)
710
+ list = location_list(window_id)
711
+ idx = list[:index]
712
+ idx ? list[:items][idx] : nil
713
+ end
714
+
715
+ def move_location_list(step, window_id: current_window_id)
716
+ list = location_list(window_id)
717
+ items = list[:items]
718
+ return nil if items.empty?
719
+
720
+ list[:index] = ((list[:index] || 0) + step.to_i) % items.length
721
+ current_location_list_item(window_id)
722
+ end
723
+
724
+ def find_window_ids_by_buffer_kind(kind)
725
+ sym = kind.to_sym
726
+ @window_order.select do |wid|
727
+ win = @windows[wid]
728
+ buf = win && @buffers[win.buffer_id]
729
+ buf && buf.kind == sym
730
+ end
731
+ end
732
+
733
+ def tabnew(path: nil)
734
+ ensure_initial_tabpage!
735
+ save_current_tabpage_state!
736
+
737
+ with_tab_autosave_suspended do
738
+ @window_order = []
739
+ @current_window_id = nil
740
+ @window_layout = :single
741
+
742
+ buffer = path ? add_buffer_from_file(path) : add_empty_buffer
743
+ add_window(buffer_id: buffer.id)
744
+ tab = new_tabpage_snapshot
745
+ @tabpages << tab
746
+ @current_tabpage_index = @tabpages.length - 1
747
+ load_tabpage_state!(tab)
748
+ return tab
749
+ end
750
+ end
751
+
752
+ def tabnext(step = 1)
753
+ return nil if @tabpages.empty?
754
+ save_current_tabpage_state!
755
+ @current_tabpage_index = (@current_tabpage_index + step) % @tabpages.length
756
+ load_tabpage_state!(@tabpages[@current_tabpage_index])
757
+ end
758
+
759
+ def tabprev(step = 1)
760
+ tabnext(-step)
761
+ end
762
+
763
+ def enter_normal_mode
764
+ @mode = :normal
765
+ @pending_count = nil
766
+ clear_visual
767
+ end
768
+
769
+ def enter_insert_mode
770
+ @mode = :insert
771
+ @pending_count = nil
772
+ end
773
+
774
+ def enter_command_line_mode(prefix = ":")
775
+ @mode = :command_line
776
+ @command_line.reset(prefix:)
777
+ @pending_count = nil
778
+ end
779
+
780
+ def cancel_command_line
781
+ @command_line.clear
782
+ enter_normal_mode
783
+ end
784
+
785
+ def echo(msg)
786
+ @message_kind = :info
787
+ @message = msg.to_s
788
+ end
789
+
790
+ def echo_error(msg)
791
+ @message_kind = :error
792
+ @message = msg.to_s
793
+ end
794
+
795
+ def clear_message
796
+ @message_kind = :info
797
+ @message = ""
798
+ end
799
+
800
+ def message_error?
801
+ !@message.to_s.empty? && @message_kind == :error
802
+ end
803
+
804
+ def text_viewport_size(rows:, cols:)
805
+ text_rows = command_area_active? ? rows - 2 : rows - 1
806
+ [text_rows, cols]
807
+ end
808
+
809
+ def command_line_active?
810
+ @mode == :command_line
811
+ end
812
+
813
+ def command_area_active?
814
+ command_line_active? || message_error?
815
+ end
816
+
817
+ private
818
+
819
+ def default_global_options
820
+ OPTION_DEFS.each_with_object({}) { |(k, v), h| h[k] = v[:default] }
821
+ end
822
+
823
+ def coerce_option_value(name, value)
824
+ defn = option_def(name)
825
+ return value unless defn
826
+
827
+ case defn[:type]
828
+ when :bool
829
+ !!value
830
+ when :int
831
+ iv = value.is_a?(Integer) ? value : Integer(value)
832
+ raise RuVim::CommandError, "#{name} must be >= 0" if iv.negative?
833
+ iv
834
+ when :string
835
+ value.nil? ? nil : value.to_s
836
+ else
837
+ value
838
+ end
839
+ rescue ArgumentError, TypeError
840
+ raise RuVim::CommandError, "Invalid value for #{name}: #{value.inspect}"
841
+ end
842
+
843
+ def write_register_payload(key, text:, type:)
844
+ if key.match?(/\A[A-Z]\z/)
845
+ base = key.downcase
846
+ prev = @registers[base]
847
+ payload = { text: "#{prev ? prev[:text] : ""}#{text}", type: type }
848
+ @registers[base] = payload
849
+ else
850
+ payload = { text: text, type: type }
851
+ @registers[key.downcase] = payload
852
+ end
853
+ payload
854
+ end
855
+
856
+ def rotate_delete_registers!(payload)
857
+ 9.downto(2) do |i|
858
+ prev = @registers[(i - 1).to_s]
859
+ if prev
860
+ @registers[i.to_s] = dup_register_payload(prev)
861
+ else
862
+ @registers.delete(i.to_s)
863
+ end
864
+ end
865
+ @registers["1"] = dup_register_payload(payload)
866
+ end
867
+
868
+ def dup_register_payload(payload)
869
+ return nil unless payload
870
+
871
+ { text: payload[:text].to_s.dup, type: payload[:type].to_sym }
872
+ end
873
+
874
+ def assign_detected_filetype(buffer)
875
+ ft = detect_filetype(buffer.path)
876
+ buffer.options["filetype"] = ft if ft && !ft.empty?
877
+ buffer
878
+ end
879
+
880
+ def intro_lines
881
+ [
882
+ "RuVim - Vi IMproved (Ruby edition)",
883
+ "",
884
+ "type :help for help",
885
+ "type :help regex for Ruby Regexp search/substitute help",
886
+ "type :q to quit",
887
+ "",
888
+ "keys i a o to start editing",
889
+ "keys / ? to search",
890
+ "keys : to enter command-line",
891
+ "",
892
+ "This is the intro screen (Vim-style).",
893
+ "It will be replaced with an empty buffer when you start editing."
894
+ ]
895
+ end
896
+
897
+ public
898
+
899
+ def jump_to_location(loc, linewise: false)
900
+ location = normalize_location(loc)
901
+ return nil unless location
902
+ return nil unless @buffers.key?(location[:buffer_id])
903
+
904
+ switch_to_buffer(location[:buffer_id]) if current_buffer.id != location[:buffer_id]
905
+ current_window.cursor_y = location[:row]
906
+ current_window.cursor_x = linewise ? 0 : location[:col]
907
+ current_window.clamp_to_buffer(current_buffer)
908
+ current_window.cursor_x = first_nonblank_col(current_buffer, current_window.cursor_y) if linewise
909
+ current_window.clamp_to_buffer(current_buffer)
910
+ current_location
911
+ end
912
+
913
+ private
914
+
915
+ def first_nonblank_col(buffer, row)
916
+ line = buffer.line_at(row)
917
+ line.index(/\S/) || 0
918
+ end
919
+
920
+ def normalize_location(loc)
921
+ return nil unless loc
922
+
923
+ {
924
+ buffer_id: Integer(loc[:buffer_id] || loc["buffer_id"]),
925
+ row: [Integer(loc[:row] || loc["row"]), 0].max,
926
+ col: [Integer(loc[:col] || loc["col"]), 0].max
927
+ }
928
+ rescue StandardError
929
+ nil
930
+ end
931
+
932
+ def same_location?(a, b)
933
+ return false unless a && b
934
+
935
+ a[:buffer_id] == b[:buffer_id] && a[:row] == b[:row] && a[:col] == b[:col]
936
+ end
937
+
938
+ def dup_macro_key(key)
939
+ case key
940
+ when String
941
+ key.dup
942
+ when Array
943
+ key.map { |v| v.is_a?(String) ? v.dup : v }
944
+ else
945
+ key
946
+ end
947
+ end
948
+
949
+ def clipboard_register?(key)
950
+ key == "+" || key == "*"
951
+ end
952
+
953
+ def write_clipboard_register(key, payload)
954
+ return unless clipboard_register?(key.downcase)
955
+
956
+ RuVim::Clipboard.write(payload[:text])
957
+ end
958
+
959
+ def read_clipboard_register(key)
960
+ text = RuVim::Clipboard.read
961
+ if text
962
+ payload = { text: text, type: text.end_with?("\n") ? :linewise : :charwise }
963
+ @registers[key] = payload
964
+ end
965
+ @registers[key]
966
+ end
967
+
968
+ def ensure_initial_tabpage!
969
+ return unless @tabpages.empty?
970
+ return if @window_order.empty?
971
+
972
+ @tabpages << new_tabpage_snapshot
973
+ @current_tabpage_index = 0
974
+ end
975
+
976
+ def save_current_tabpage_state!
977
+ return if @current_tabpage_index.nil?
978
+ return if @tabpages.empty?
979
+
980
+ @tabpages[@current_tabpage_index] = new_tabpage_snapshot(id: @tabpages[@current_tabpage_index][:id])
981
+ end
982
+
983
+ def load_tabpage_state!(tab)
984
+ @window_order = tab[:window_order].dup
985
+ @current_window_id = tab[:current_window_id]
986
+ @window_layout = tab[:window_layout]
987
+ current_window
988
+ end
989
+
990
+ def new_tabpage_snapshot(id: nil)
991
+ {
992
+ id: id || next_tabpage_id,
993
+ window_order: @window_order.dup,
994
+ current_window_id: @current_window_id,
995
+ window_layout: @window_layout
996
+ }
997
+ end
998
+
999
+ def next_buffer_id
1000
+ id = @next_buffer_id
1001
+ @next_buffer_id += 1
1002
+ id
1003
+ end
1004
+
1005
+ def next_window_id
1006
+ id = @next_window_id
1007
+ @next_window_id += 1
1008
+ id
1009
+ end
1010
+
1011
+ def next_tabpage_id
1012
+ id = @next_tabpage_id
1013
+ @next_tabpage_id += 1
1014
+ id
1015
+ end
1016
+
1017
+ def with_tab_autosave_suspended
1018
+ prev = @suspend_tab_autosave
1019
+ @suspend_tab_autosave = true
1020
+ yield
1021
+ ensure
1022
+ @suspend_tab_autosave = prev
1023
+ end
1024
+ end
1025
+ end