ruvim 0.1.0 → 0.2.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.
@@ -1,3 +1,6 @@
1
+ require "tempfile"
2
+ require "open3"
3
+
1
4
  module RuVim
2
5
  class GlobalCommands
3
6
  include Singleton
@@ -12,11 +15,11 @@ module RuVim
12
15
  end
13
16
 
14
17
  def cursor_left(ctx, count:, **)
15
- ctx.window.move_left(ctx.buffer, count)
18
+ move_cursor_horizontally(ctx, direction: :left, count:)
16
19
  end
17
20
 
18
21
  def cursor_right(ctx, count:, **)
19
- ctx.window.move_right(ctx.buffer, count)
22
+ move_cursor_horizontally(ctx, direction: :right, count:)
20
23
  end
21
24
 
22
25
  def cursor_up(ctx, count:, **)
@@ -37,6 +40,38 @@ module RuVim
37
40
  ctx.window.move_down(ctx.buffer, page_lines * [count.to_i, 1].max)
38
41
  end
39
42
 
43
+ def cursor_page_up_default(ctx, count:, bang:, **)
44
+ call(:cursor_page_up, ctx, count:, bang:, kwargs: { page_lines: current_page_step_lines(ctx) })
45
+ end
46
+
47
+ def cursor_page_down_default(ctx, count:, bang:, **)
48
+ call(:cursor_page_down, ctx, count:, bang:, kwargs: { page_lines: current_page_step_lines(ctx) })
49
+ end
50
+
51
+ def cursor_page_up_half(ctx, count:, bang:, **)
52
+ call(:cursor_page_up, ctx, count:, bang:, kwargs: { page_lines: current_half_page_step_lines(ctx) })
53
+ end
54
+
55
+ def cursor_page_down_half(ctx, count:, bang:, **)
56
+ call(:cursor_page_down, ctx, count:, bang:, kwargs: { page_lines: current_half_page_step_lines(ctx) })
57
+ end
58
+
59
+ def window_scroll_up(ctx, kwargs:, count:, **)
60
+ scroll_window_vertically(ctx, direction: :up, lines: kwargs[:lines] || kwargs["lines"], view_height: kwargs[:view_height] || kwargs["view_height"], count:)
61
+ end
62
+
63
+ def window_scroll_down(ctx, kwargs:, count:, **)
64
+ scroll_window_vertically(ctx, direction: :down, lines: kwargs[:lines] || kwargs["lines"], view_height: kwargs[:view_height] || kwargs["view_height"], count:)
65
+ end
66
+
67
+ def window_scroll_up_line(ctx, count:, bang:, **)
68
+ call(:window_scroll_up, ctx, count:, bang:, kwargs: { lines: 1, view_height: current_view_height(ctx) + 1 })
69
+ end
70
+
71
+ def window_scroll_down_line(ctx, count:, bang:, **)
72
+ call(:window_scroll_down, ctx, count:, bang:, kwargs: { lines: 1, view_height: current_view_height(ctx) + 1 })
73
+ end
74
+
40
75
  def cursor_line_start(ctx, **)
41
76
  ctx.window.cursor_x = 0
42
77
  ctx.window.clamp_to_buffer(ctx.buffer)
@@ -149,6 +184,7 @@ module RuVim
149
184
  x = ctx.buffer.line_length(y)
150
185
  ctx.buffer.begin_change_group
151
186
  new_y, new_x = ctx.buffer.insert_newline(y, x)
187
+ new_x = apply_autoindent_to_newline(ctx, row: new_y, previous_row: y, start_col: new_x)
152
188
  ctx.window.cursor_y = new_y
153
189
  ctx.window.cursor_x = new_x
154
190
  ctx.editor.enter_insert_mode
@@ -159,8 +195,10 @@ module RuVim
159
195
  materialize_intro_buffer_if_needed(ctx)
160
196
  y = ctx.window.cursor_y
161
197
  ctx.buffer.begin_change_group
162
- ctx.buffer.insert_newline(y, 0)
163
- ctx.window.cursor_x = 0
198
+ _new_y, new_x = ctx.buffer.insert_newline(y, 0)
199
+ new_x = apply_autoindent_to_newline(ctx, row: y, previous_row: y + 1, start_col: 0)
200
+ ctx.window.cursor_y = y
201
+ ctx.window.cursor_x = new_x
164
202
  ctx.editor.enter_insert_mode
165
203
  ctx.editor.echo("-- INSERT --")
166
204
  end
@@ -181,12 +219,14 @@ module RuVim
181
219
  end
182
220
 
183
221
  def window_split(ctx, **)
184
- ctx.editor.split_current_window(layout: :horizontal)
222
+ place = ctx.editor.effective_option("splitbelow", window: ctx.window, buffer: ctx.buffer) ? :after : :before
223
+ ctx.editor.split_current_window(layout: :horizontal, place:)
185
224
  ctx.editor.echo("split")
186
225
  end
187
226
 
188
227
  def window_vsplit(ctx, **)
189
- ctx.editor.split_current_window(layout: :vertical)
228
+ place = ctx.editor.effective_option("splitright", window: ctx.window, buffer: ctx.buffer) ? :after : :before
229
+ ctx.editor.split_current_window(layout: :vertical, place:)
190
230
  ctx.editor.echo("vsplit")
191
231
  end
192
232
 
@@ -212,9 +252,11 @@ module RuVim
212
252
 
213
253
  def tab_new(ctx, argv:, **)
214
254
  path = argv[0]
215
- if ctx.buffer.modified?
216
- ctx.editor.echo_error("Unsaved changes (use :w or :q!)")
217
- return
255
+ if ctx.buffer.modified? && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
256
+ unless maybe_autowrite_before_switch(ctx)
257
+ ctx.editor.echo_error("Unsaved changes (use :w or :q!)")
258
+ return
259
+ end
218
260
  end
219
261
  tab = ctx.editor.tabnew(path: path)
220
262
  if path && !path.empty?
@@ -423,7 +465,7 @@ module RuVim
423
465
  when "w"
424
466
  y = ctx.window.cursor_y
425
467
  x = ctx.window.cursor_x
426
- target = advance_word_forward(ctx.buffer, y, x, count)
468
+ target = advance_word_forward(ctx.buffer, y, x, count, editor: ctx.editor, window: ctx.window)
427
469
  target ||= { row: y, col: x }
428
470
  text = ctx.buffer.span_text(y, x, target[:row], target[:col])
429
471
  store_yank_register(ctx, text:, type: :charwise)
@@ -587,8 +629,14 @@ module RuVim
587
629
  end
588
630
 
589
631
  if ctx.buffer.modified? && !bang
590
- ctx.editor.echo_error("Unsaved changes (use :e! to discard and open)")
591
- return
632
+ if ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
633
+ # hidden permits abandoning a modified buffer without forcing write.
634
+ elsif maybe_autowrite_before_switch(ctx)
635
+ # autowrite handled
636
+ else
637
+ ctx.editor.echo_error("Unsaved changes (use :e! to discard and open)")
638
+ return
639
+ end
592
640
  end
593
641
 
594
642
  new_buffer = ctx.editor.add_buffer_from_file(path)
@@ -596,6 +644,29 @@ module RuVim
596
644
  ctx.editor.echo(File.exist?(path) ? "\"#{path}\" #{new_buffer.line_count}L" : "\"#{path}\" [New File]")
597
645
  end
598
646
 
647
+ def file_goto_under_cursor(ctx, **)
648
+ token = file_token_under_cursor(ctx.buffer, ctx.window)
649
+ if token.nil? || token.empty?
650
+ ctx.editor.echo_error("No file under cursor")
651
+ return
652
+ end
653
+
654
+ path = resolve_gf_path(ctx, token)
655
+ unless path
656
+ ctx.editor.echo_error("File not found: #{token}")
657
+ return
658
+ end
659
+
660
+ if ctx.buffer.modified? && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
661
+ unless maybe_autowrite_before_switch(ctx)
662
+ ctx.editor.echo_error("Unsaved changes (set hidden or :w)")
663
+ return
664
+ end
665
+ end
666
+
667
+ ctx.editor.open_path(path)
668
+ end
669
+
599
670
  def buffer_list(ctx, **)
600
671
  current_id = ctx.buffer.id
601
672
  alt_id = ctx.editor.alternate_buffer_id
@@ -639,6 +710,28 @@ module RuVim
639
710
  switch_buffer_id(ctx, target_id, bang:)
640
711
  end
641
712
 
713
+ def buffer_delete(ctx, argv:, bang:, **)
714
+ arg = argv[0]
715
+ target_id =
716
+ if arg.nil? || arg.empty?
717
+ ctx.buffer.id
718
+ elsif arg == "#"
719
+ ctx.editor.alternate_buffer_id || raise(RuVim::CommandError, "No alternate buffer")
720
+ elsif arg.match?(/\A\d+\z/)
721
+ arg.to_i
722
+ else
723
+ find_buffer_by_name(ctx.editor, arg)&.id || raise(RuVim::CommandError, "No such buffer: #{arg}")
724
+ end
725
+
726
+ target = ctx.editor.buffers[target_id] || raise(RuVim::CommandError, "No such buffer: #{target_id}")
727
+ if target.modified? && !bang
728
+ raise RuVim::CommandError, "No write since last change (use :bdelete! to discard)"
729
+ end
730
+
731
+ ctx.editor.delete_buffer(target_id)
732
+ ctx.editor.echo("buffer #{target_id} deleted")
733
+ end
734
+
642
735
  def ex_help(ctx, argv: [], **)
643
736
  topic = argv.first.to_s
644
737
  registry = RuVim::ExCommandRegistry.instance
@@ -724,13 +817,98 @@ module RuVim
724
817
  raise RuVim::CommandError, "Usage: :ruby <code>" if code.strip.empty?
725
818
 
726
819
  b = binding
820
+ # Use local_variable_set for eval locals to avoid "assigned but unused variable"
821
+ # warnings while still exposing editor/buffer/window in :ruby.
727
822
  b.local_variable_set(:editor, ctx.editor)
728
823
  b.local_variable_set(:buffer, ctx.buffer)
729
824
  b.local_variable_set(:window, ctx.window)
730
- result = eval(code, b) # rubocop:disable Security/Eval
731
- ctx.editor.echo(result.nil? ? "ruby: nil" : "ruby: #{result.inspect}")
825
+ saved_stdout = STDOUT.dup
826
+ saved_stderr = STDERR.dup
827
+ original_g_stdout = $stdout
828
+ original_g_stderr = $stderr
829
+ result = nil
830
+ stdout_text = ""
831
+ stderr_text = ""
832
+ Tempfile.create("ruvim-ruby-stdout") do |outf|
833
+ Tempfile.create("ruvim-ruby-stderr") do |errf|
834
+ STDOUT.reopen(outf)
835
+ STDERR.reopen(errf)
836
+ $stdout = STDOUT
837
+ $stderr = STDERR
838
+ result = eval(code, b) # rubocop:disable Security/Eval
839
+ STDOUT.flush
840
+ STDERR.flush
841
+ outf.flush
842
+ errf.flush
843
+ outf.rewind
844
+ errf.rewind
845
+ stdout_text = outf.read.to_s
846
+ stderr_text = errf.read.to_s
847
+ end
848
+ end
849
+ if !stdout_text.empty? || !stderr_text.empty?
850
+ lines = ["Ruby output", ""]
851
+ unless stdout_text.empty?
852
+ lines << "[stdout]"
853
+ lines.concat(stdout_text.lines(chomp: true))
854
+ lines << ""
855
+ end
856
+ unless stderr_text.empty?
857
+ lines << "[stderr]"
858
+ lines.concat(stderr_text.lines(chomp: true))
859
+ lines << ""
860
+ end
861
+ lines << "[result]"
862
+ lines << (result.nil? ? "nil" : result.inspect)
863
+ ctx.editor.show_help_buffer!(title: "[Ruby Output]", lines:, filetype: "ruby")
864
+ else
865
+ ctx.editor.echo(result.nil? ? "ruby: nil" : "ruby: #{result.inspect}")
866
+ end
732
867
  rescue StandardError => e
733
868
  raise RuVim::CommandError, "Ruby error: #{e.class}: #{e.message}"
869
+ ensure
870
+ if defined?(saved_stdout) && saved_stdout
871
+ STDOUT.reopen(saved_stdout)
872
+ saved_stdout.close unless saved_stdout.closed?
873
+ end
874
+ if defined?(saved_stderr) && saved_stderr
875
+ STDERR.reopen(saved_stderr)
876
+ saved_stderr.close unless saved_stderr.closed?
877
+ end
878
+ $stdout = (defined?(original_g_stdout) && original_g_stdout) ? original_g_stdout : STDOUT
879
+ $stderr = (defined?(original_g_stderr) && original_g_stderr) ? original_g_stderr : STDERR
880
+ end
881
+
882
+ def ex_shell(ctx, command:, **)
883
+ raise RuVim::CommandError, "Restricted mode: :! is disabled" if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
884
+
885
+ cmd = command.to_s
886
+ raise RuVim::CommandError, "Usage: :!<command>" if cmd.strip.empty?
887
+
888
+ shell = ENV["SHELL"].to_s
889
+ shell = "/bin/sh" if shell.empty?
890
+ stdout_text, stderr_text, status = Open3.capture3(shell, "-c", cmd)
891
+
892
+ if !stdout_text.to_s.empty? || !stderr_text.to_s.empty?
893
+ lines = ["Shell output", "", "[command]", cmd, ""]
894
+ unless stdout_text.to_s.empty?
895
+ lines << "[stdout]"
896
+ lines.concat(stdout_text.to_s.lines(chomp: true))
897
+ lines << ""
898
+ end
899
+ unless stderr_text.to_s.empty?
900
+ lines << "[stderr]"
901
+ lines.concat(stderr_text.to_s.lines(chomp: true))
902
+ lines << ""
903
+ end
904
+ lines << "[status]"
905
+ lines << "exit #{status.exitstatus}"
906
+ ctx.editor.show_help_buffer!(title: "[Shell Output]", lines:, filetype: "sh")
907
+ else
908
+ ctx.editor.echo("shell exit #{status.exitstatus}")
909
+ end
910
+ rescue Errno::ENOENT => e
911
+ raise RuVim::CommandError, "Shell error: #{e.message}"
734
912
  end
735
913
 
736
914
  def ex_commands(ctx, **)
@@ -783,7 +961,7 @@ module RuVim
783
961
  end
784
962
 
785
963
  def ex_copen(ctx, **)
786
- open_list_window(ctx, kind: :quickfix, title: "[Quickfix]", lines: quickfix_buffer_lines(ctx.editor))
964
+ open_list_window(ctx, kind: :quickfix, title: "[Quickfix]", lines: quickfix_buffer_lines(ctx.editor), source_window_id: ctx.window.id)
787
965
  end
788
966
 
789
967
  def ex_cclose(ctx, **)
@@ -811,7 +989,8 @@ module RuVim
811
989
  end
812
990
 
813
991
  def ex_lopen(ctx, **)
814
- open_list_window(ctx, kind: :location_list, title: "[Location List]", lines: location_list_buffer_lines(ctx.editor, ctx.window.id))
992
+ open_list_window(ctx, kind: :location_list, title: "[Location List]", lines: location_list_buffer_lines(ctx.editor, ctx.window.id),
993
+ source_window_id: ctx.window.id)
815
994
  end
816
995
 
817
996
  def ex_lclose(ctx, **)
@@ -933,10 +1112,11 @@ module RuVim
933
1112
  ]
934
1113
  end
935
1114
 
936
- def open_list_window(ctx, kind:, title:, lines:)
1115
+ def open_list_window(ctx, kind:, title:, lines:, source_window_id:)
937
1116
  editor = ctx.editor
938
1117
  editor.split_current_window(layout: :horizontal)
939
1118
  buffer = editor.add_virtual_buffer(kind:, name: title, lines:, filetype: "qf", readonly: true, modifiable: false)
1119
+ buffer.options["ruvim_list_source_window_id"] = source_window_id
940
1120
  editor.switch_to_buffer(buffer.id)
941
1121
  editor.echo(title)
942
1122
  buffer
@@ -979,9 +1159,11 @@ module RuVim
979
1159
  raise RuVim::CommandError, "No such buffer: #{buffer_id}"
980
1160
  end
981
1161
 
982
- if ctx.buffer.modified? && ctx.buffer.id != buffer_id && !bang
983
- ctx.editor.echo_error("Unsaved changes (use :w or :buffer! / :bnext! / :bprev!)")
984
- return
1162
+ if ctx.buffer.modified? && ctx.buffer.id != buffer_id && !bang && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
1163
+ unless maybe_autowrite_before_switch(ctx)
1164
+ ctx.editor.echo_error("Unsaved changes (use :w or :buffer! / :bnext! / :bprev!)")
1165
+ return
1166
+ end
985
1167
  end
986
1168
 
987
1169
  record_jump(ctx)
@@ -1017,28 +1199,55 @@ module RuVim
1017
1199
  end.join("\n")
1018
1200
  end
1019
1201
 
1202
+ def apply_autoindent_to_newline(ctx, row:, previous_row:, start_col: 0)
1203
+ return start_col unless ctx.editor.effective_option("autoindent", window: ctx.window, buffer: ctx.buffer)
1204
+
1205
+ prev = ctx.buffer.line_at(previous_row)
1206
+ indent = prev[/\A[ \t]*/].to_s
1207
+
1208
+ if ctx.editor.effective_option("smartindent", window: ctx.window, buffer: ctx.buffer)
1209
+ trimmed = prev.rstrip
1210
+ if trimmed.end_with?("{", "[", "(")
1211
+ sw = ctx.editor.effective_option("shiftwidth", window: ctx.window, buffer: ctx.buffer).to_i
1212
+ sw = 2 if sw <= 0
1213
+ indent += " " * sw
1214
+ end
1215
+ end
1216
+
1217
+ return start_col if indent.empty?
1218
+
1219
+ _y, x = ctx.buffer.insert_text(row, start_col, indent)
1220
+ x
1221
+ end
1222
+
1020
1223
  def search_current_word(ctx, exact:, direction:)
1021
- word = current_word_under_cursor(ctx.buffer, ctx.window)
1224
+ keyword_rx = keyword_char_regex(ctx.editor, ctx.buffer, ctx.window)
1225
+ word = current_word_under_cursor(ctx.buffer, ctx.window, keyword_rx:)
1022
1226
  if word.nil? || word.empty?
1023
1227
  ctx.editor.echo("No word under cursor")
1024
1228
  return
1025
1229
  end
1026
1230
 
1027
- pattern = exact ? "\\b#{Regexp.escape(word)}\\b" : Regexp.escape(word)
1231
+ pattern =
1232
+ if exact
1233
+ "(?<!#{keyword_rx.source})#{Regexp.escape(word)}(?!#{keyword_rx.source})"
1234
+ else
1235
+ Regexp.escape(word)
1236
+ end
1028
1237
  ctx.editor.set_last_search(pattern:, direction:)
1029
1238
  move_to_search(ctx, pattern:, direction:, count: 1)
1030
1239
  end
1031
1240
 
1032
- def current_word_under_cursor(buffer, window)
1241
+ def current_word_under_cursor(buffer, window, keyword_rx: /[[:alnum:]_]/)
1033
1242
  line = buffer.line_at(window.cursor_y)
1034
1243
  return nil if line.empty?
1035
1244
 
1036
1245
  x = [window.cursor_x, line.length - 1].min
1037
1246
  return nil if x.negative?
1038
1247
 
1039
- if line[x] !~ /[[:alnum:]_]/
1248
+ if !keyword_char?(line[x], keyword_rx)
1040
1249
  left = x - 1
1041
- if left >= 0 && line[left] =~ /[[:alnum:]_]/
1250
+ if left >= 0 && keyword_char?(line[left], keyword_rx)
1042
1251
  x = left
1043
1252
  else
1044
1253
  return nil
@@ -1046,9 +1255,9 @@ module RuVim
1046
1255
  end
1047
1256
 
1048
1257
  s = x
1049
- s -= 1 while s.positive? && line[s - 1] =~ /[[:alnum:]_]/
1258
+ s -= 1 while s.positive? && keyword_char?(line[s - 1], keyword_rx)
1050
1259
  e = x + 1
1051
- e += 1 while e < line.length && line[e] =~ /[[:alnum:]_]/
1260
+ e += 1 while e < line.length && keyword_char?(line[e], keyword_rx)
1052
1261
  line[s...e]
1053
1262
  end
1054
1263
 
@@ -1132,7 +1341,7 @@ module RuVim
1132
1341
  def delete_word_forward(ctx, count)
1133
1342
  y = ctx.window.cursor_y
1134
1343
  x = ctx.window.cursor_x
1135
- target = advance_word_forward(ctx.buffer, y, x, count)
1344
+ target = advance_word_forward(ctx.buffer, y, x, count, editor: ctx.editor, window: ctx.window)
1136
1345
  return true unless target
1137
1346
 
1138
1347
  deleted = ctx.buffer.span_text(y, x, target[:row], target[:col])
@@ -1194,12 +1403,13 @@ module RuVim
1194
1403
  true
1195
1404
  end
1196
1405
 
1197
- def advance_word_forward(buffer, row, col, count)
1406
+ def advance_word_forward(buffer, row, col, count, editor: nil, window: nil)
1198
1407
  text = buffer.lines.join("\n")
1199
1408
  flat = cursor_to_offset(buffer, row, col)
1200
1409
  idx = flat
1410
+ keyword_rx = keyword_char_regex(editor, buffer, window)
1201
1411
  count.times do
1202
- idx = next_word_start_offset(text, idx)
1412
+ idx = next_word_start_offset(text, idx, keyword_rx)
1203
1413
  return nil unless idx
1204
1414
  end
1205
1415
  offset_to_cursor(buffer, idx)
@@ -1214,9 +1424,9 @@ module RuVim
1214
1424
  count.times do
1215
1425
  target =
1216
1426
  case kind
1217
- when :forward_start then advance_word_forward(buffer, target[:row], target[:col], 1)
1218
- when :backward_start then advance_word_backward(buffer, target[:row], target[:col], 1)
1219
- when :forward_end then advance_word_end(buffer, target[:row], target[:col], 1)
1427
+ when :forward_start then advance_word_forward(buffer, target[:row], target[:col], 1, editor: ctx.editor, window: ctx.window)
1428
+ when :backward_start then advance_word_backward(buffer, target[:row], target[:col], 1, editor: ctx.editor, window: ctx.window)
1429
+ when :forward_end then advance_word_end(buffer, target[:row], target[:col], 1, editor: ctx.editor, window: ctx.window)
1220
1430
  end
1221
1431
  break unless target
1222
1432
  end
@@ -1227,60 +1437,62 @@ module RuVim
1227
1437
  ctx.window.clamp_to_buffer(buffer)
1228
1438
  end
1229
1439
 
1230
- def advance_word_backward(buffer, row, col, _count)
1440
+ def advance_word_backward(buffer, row, col, _count, editor: nil, window: nil)
1231
1441
  text = buffer.lines.join("\n")
1232
1442
  idx = cursor_to_offset(buffer, row, col)
1233
1443
  idx = [idx - 1, 0].max
1234
- while idx > 0 && char_class(text[idx]) == :space
1444
+ keyword_rx = keyword_char_regex(editor, buffer, window)
1445
+ while idx > 0 && char_class(text[idx], keyword_rx) == :space
1235
1446
  idx -= 1
1236
1447
  end
1237
- cls = char_class(text[idx])
1238
- while idx > 0 && char_class(text[idx - 1]) == cls && cls != :space
1448
+ cls = char_class(text[idx], keyword_rx)
1449
+ while idx > 0 && char_class(text[idx - 1], keyword_rx) == cls && cls != :space
1239
1450
  idx -= 1
1240
1451
  end
1241
- while idx > 0 && char_class(text[idx]) == :space
1452
+ while idx > 0 && char_class(text[idx], keyword_rx) == :space
1242
1453
  idx += 1
1243
1454
  end
1244
1455
  offset_to_cursor(buffer, idx)
1245
1456
  end
1246
1457
 
1247
- def advance_word_end(buffer, row, col, _count)
1458
+ def advance_word_end(buffer, row, col, _count, editor: nil, window: nil)
1248
1459
  text = buffer.lines.join("\n")
1249
1460
  idx = cursor_to_offset(buffer, row, col)
1250
1461
  n = text.length
1251
- while idx < n && char_class(text[idx]) == :space
1462
+ keyword_rx = keyword_char_regex(editor, buffer, window)
1463
+ while idx < n && char_class(text[idx], keyword_rx) == :space
1252
1464
  idx += 1
1253
1465
  end
1254
1466
  return nil if idx >= n
1255
1467
 
1256
- cls = char_class(text[idx])
1257
- idx += 1 while idx + 1 < n && char_class(text[idx + 1]) == cls && cls != :space
1468
+ cls = char_class(text[idx], keyword_rx)
1469
+ idx += 1 while idx + 1 < n && char_class(text[idx + 1], keyword_rx) == cls && cls != :space
1258
1470
  offset_to_cursor(buffer, idx)
1259
1471
  end
1260
1472
 
1261
- def next_word_start_offset(text, from_offset)
1473
+ def next_word_start_offset(text, from_offset, keyword_rx = nil)
1262
1474
  i = [from_offset, 0].max
1263
1475
  n = text.length
1264
1476
  return nil if i >= n
1265
1477
 
1266
- cls = char_class(text[i])
1478
+ cls = char_class(text[i], keyword_rx)
1267
1479
  if cls == :word
1268
- i += 1 while i < n && char_class(text[i]) == :word
1480
+ i += 1 while i < n && char_class(text[i], keyword_rx) == :word
1269
1481
  elsif cls == :space
1270
- i += 1 while i < n && char_class(text[i]) == :space
1482
+ i += 1 while i < n && char_class(text[i], keyword_rx) == :space
1271
1483
  else
1272
1484
  i += 1
1273
1485
  end
1274
- i += 1 while i < n && char_class(text[i]) == :space
1486
+ i += 1 while i < n && char_class(text[i], keyword_rx) == :space
1275
1487
  return n if i > n
1276
1488
 
1277
1489
  i <= n ? i : nil
1278
1490
  end
1279
1491
 
1280
- def char_class(ch)
1492
+ def char_class(ch, keyword_rx = nil)
1281
1493
  return :space if ch == "\n"
1282
1494
  return :space if ch =~ /\s/
1283
- return :word if ch =~ /[[:alnum:]_]/
1495
+ return :word if keyword_char?(ch, keyword_rx)
1284
1496
  :punct
1285
1497
  end
1286
1498
 
@@ -1306,11 +1518,12 @@ module RuVim
1306
1518
  x = nxt
1307
1519
  end
1308
1520
 
1309
- cls = line[x] =~ /[[:alnum:]_]/ ? :word : :punct
1521
+ keyword_rx = keyword_char_regex(nil, buffer, window)
1522
+ cls = keyword_char?(line[x], keyword_rx) ? :word : :punct
1310
1523
  start_col = x
1311
- start_col -= 1 while start_col.positive? && same_word_class?(line[start_col - 1], cls)
1524
+ start_col -= 1 while start_col.positive? && same_word_class?(line[start_col - 1], cls, keyword_rx)
1312
1525
  end_col = x + 1
1313
- end_col += 1 while end_col < line.length && same_word_class?(line[end_col], cls)
1526
+ end_col += 1 while end_col < line.length && same_word_class?(line[end_col], cls, keyword_rx)
1314
1527
 
1315
1528
  if around
1316
1529
  while end_col < line.length && line[end_col] =~ /\s/
@@ -1493,15 +1706,146 @@ module RuVim
1493
1706
  nil
1494
1707
  end
1495
1708
 
1496
- def same_word_class?(ch, cls)
1709
+ def same_word_class?(ch, cls, keyword_rx = nil)
1497
1710
  return false if ch.nil?
1498
1711
  case cls
1499
- when :word then ch =~ /[[:alnum:]_]/
1500
- when :punct then !(ch =~ /[[:alnum:]_\s]/)
1712
+ when :word then keyword_char?(ch, keyword_rx)
1713
+ when :punct then !(keyword_char?(ch, keyword_rx) || ch =~ /\s/)
1501
1714
  else false
1502
1715
  end
1503
1716
  end
1504
1717
 
1718
+ def keyword_char?(ch, keyword_rx = nil)
1719
+ return false if ch.nil?
1720
+
1721
+ (keyword_rx || /[[:alnum:]_]/).match?(ch)
1722
+ end
1723
+
1724
+ def keyword_char_regex(editor, buffer, window)
1725
+ win = window || editor&.current_window
1726
+ buf = buffer || editor&.current_buffer
1727
+ raw =
1728
+ if editor
1729
+ editor.effective_option("iskeyword", window: win, buffer: buf).to_s
1730
+ else
1731
+ buf&.options&.fetch("iskeyword", nil).to_s
1732
+ end
1733
+ RuVim::KeywordChars.regex(raw)
1734
+ end
1735
+
1736
+ def move_cursor_horizontally(ctx, direction:, count:)
1737
+ count = [count.to_i, 1].max
1738
+ allow_wrap = whichwrap_allows?(ctx, direction)
1739
+ virtualedit_mode = virtualedit_mode(ctx)
1740
+ count.times do
1741
+ line = ctx.buffer.line_at(ctx.window.cursor_y)
1742
+ if direction == :left
1743
+ if ctx.window.cursor_x > line.length && virtualedit_mode
1744
+ ctx.window.cursor_x -= 1
1745
+ elsif ctx.window.cursor_x.positive?
1746
+ ctx.window.cursor_x = RuVim::TextMetrics.previous_grapheme_char_index(line, ctx.window.cursor_x)
1747
+ elsif allow_wrap && ctx.window.cursor_y.positive?
1748
+ ctx.window.cursor_y -= 1
1749
+ ctx.window.cursor_x = ctx.buffer.line_length(ctx.window.cursor_y)
1750
+ end
1751
+ else
1752
+ max_right =
1753
+ case virtualedit_mode
1754
+ when :all then line.length + count + 1024 # practical cap; clamp below uses current cursor
1755
+ when :onemore then line.length + 1
1756
+ else line.length
1757
+ end
1758
+ if ctx.window.cursor_x < max_right
1759
+ ctx.window.cursor_x =
1760
+ if virtualedit_mode == :onemore && ctx.window.cursor_x == line.length
1761
+ line.length + 1
1762
+ elsif virtualedit_mode == :all && ctx.window.cursor_x >= line.length
1763
+ ctx.window.cursor_x + 1
1764
+ else
1765
+ RuVim::TextMetrics.next_grapheme_char_index(line, ctx.window.cursor_x)
1766
+ end
1767
+ ctx.window.cursor_x = [ctx.window.cursor_x, max_right].min
1768
+ elsif allow_wrap && ctx.window.cursor_y < ctx.buffer.line_count - 1
1769
+ ctx.window.cursor_y += 1
1770
+ ctx.window.cursor_x = 0
1771
+ end
1772
+ end
1773
+ end
1774
+ extra =
1775
+ case virtualedit_mode
1776
+ when :all
1777
+ [ctx.window.cursor_x - ctx.buffer.line_length(ctx.window.cursor_y), 0].max
1778
+ when :onemore
1779
+ 1
1780
+ else
1781
+ 0
1782
+ end
1783
+ ctx.window.clamp_to_buffer(ctx.buffer, max_extra_col: extra)
1784
+ end
1785
+
1786
+ def whichwrap_allows?(ctx, direction)
1787
+ toks = ctx.editor.effective_option("whichwrap", window: ctx.window, buffer: ctx.buffer).to_s
1788
+ .split(",").map { |s| s.strip.downcase }.reject(&:empty?)
1789
+ return false if toks.empty?
1790
+
1791
+ if direction == :left
1792
+ toks.include?("h") || toks.include?("<") || toks.include?("left")
1793
+ else
1794
+ toks.include?("l") || toks.include?(">") || toks.include?("right")
1795
+ end
1796
+ end
1797
+
1798
+ def virtualedit_mode(ctx)
1799
+ spec = ctx.editor.effective_option("virtualedit", window: ctx.window, buffer: ctx.buffer).to_s
1800
+ toks = spec.split(",").map { |s| s.strip.downcase }
1801
+ return :all if toks.include?("all")
1802
+ return :onemore if toks.include?("onemore")
1803
+
1804
+ nil
1805
+ end
1806
+
1807
+ def current_view_height(ctx)
1808
+ hint = ctx.editor.respond_to?(:current_window_view_height_hint) ? ctx.editor.current_window_view_height_hint : nil
1809
+ [hint.to_i, 1].max
1810
+ rescue StandardError
1811
+ 1
1812
+ end
1813
+
1814
+ def current_page_step_lines(ctx)
1815
+ [current_view_height(ctx) - 1, 1].max
1816
+ end
1817
+
1818
+ def current_half_page_step_lines(ctx)
1819
+ [current_view_height(ctx) / 2, 1].max
1820
+ end
1821
+
1822
+ def scroll_window_vertically(ctx, direction:, lines:, view_height:, count:)
1823
+ step = [[lines.to_i, 1].max * [count.to_i, 1].max, 1].max
1824
+ height = [view_height.to_i, 1].max
1825
+ max_row_offset = [ctx.buffer.line_count - height, 0].max
1826
+
1827
+ before = ctx.window.row_offset.to_i
1828
+ after =
1829
+ if direction == :up
1830
+ [before - step, 0].max
1831
+ else
1832
+ [before + step, max_row_offset].min
1833
+ end
1834
+ return if after == before
1835
+
1836
+ ctx.window.row_offset = after
1837
+
1838
+ # Vim-like behavior: scroll viewport first, then keep cursor inside it.
1839
+ top = after
1840
+ bottom = after + height - 1
1841
+ if ctx.window.cursor_y < top
1842
+ ctx.window.cursor_y = top
1843
+ elsif ctx.window.cursor_y > bottom
1844
+ ctx.window.cursor_y = bottom
1845
+ end
1846
+ ctx.window.clamp_to_buffer(ctx.buffer)
1847
+ end
1848
+
1505
1849
  def cursor_to_offset(buffer, row, col)
1506
1850
  offset = 0
1507
1851
  row.times { |r| offset += buffer.line_length(r) + 1 }
@@ -1612,6 +1956,107 @@ module RuVim
1612
1956
  nil
1613
1957
  end
1614
1958
 
1959
+ def maybe_autowrite_before_switch(ctx)
1960
+ return false unless ctx.editor.effective_option("autowrite", window: ctx.window, buffer: ctx.buffer)
1961
+ return false unless ctx.buffer.file_buffer?
1962
+ return false unless ctx.buffer.path && !ctx.buffer.path.empty?
1963
+
1964
+ ctx.buffer.write_to(ctx.buffer.path)
1965
+ true
1966
+ rescue StandardError
1967
+ false
1968
+ end
1969
+
1970
+ def file_token_under_cursor(buffer, window)
1971
+ line = buffer.line_at(window.cursor_y)
1972
+ return nil if line.empty?
1973
+
1974
+ x = [[window.cursor_x, 0].max, [line.length - 1, 0].max].min
1975
+ file_char = /[[:alnum:]_\.\/~-]/
1976
+ if line[x] !~ file_char
1977
+ left = x - 1
1978
+ right = x + 1
1979
+ if left >= 0 && line[left] =~ file_char
1980
+ x = left
1981
+ elsif right < line.length && line[right] =~ file_char
1982
+ x = right
1983
+ else
1984
+ return nil
1985
+ end
1986
+ end
1987
+
1988
+ s = x
1989
+ e = x + 1
1990
+ s -= 1 while s.positive? && line[s - 1] =~ file_char
1991
+ e += 1 while e < line.length && line[e] =~ file_char
1992
+ line[s...e]
1993
+ end
1994
+
1995
+ def resolve_gf_path(ctx, token)
1996
+ candidates = gf_candidate_paths(ctx, token.to_s)
1997
+ candidates.find { |p| File.file?(p) || File.directory?(p) }
1998
+ end
1999
+
2000
+ def gf_candidate_paths(ctx, token)
2001
+ suffixes = gf_suffixes(ctx)
2002
+ names = [token]
2003
+ if File.extname(token).empty?
2004
+ suffixes.each { |suf| names << "#{token}#{suf}" }
2005
+ end
2006
+ names.uniq!
2007
+
2008
+ if token.start_with?("/", "~/")
2009
+ return names.map { |n| File.expand_path(n) }.uniq
2010
+ end
2011
+
2012
+ base_dirs = gf_search_dirs(ctx)
2013
+ base_dirs.product(names).map { |dir, name| File.expand_path(name, dir) }.uniq
2014
+ end
2015
+
2016
+ def gf_search_dirs(ctx)
2017
+ current_dir = if ctx.buffer.path && !ctx.buffer.path.empty?
2018
+ File.dirname(File.expand_path(ctx.buffer.path))
2019
+ else
2020
+ Dir.pwd
2021
+ end
2022
+ raw = ctx.editor.effective_option("path", window: ctx.window, buffer: ctx.buffer).to_s
2023
+ dirs = raw.split(",").map(&:strip).reject(&:empty?)
2024
+ dirs = ["."] if dirs.empty?
2025
+ dirs.flat_map do |dir|
2026
+ expand_gf_path_entry(dir, current_dir)
2027
+ end.uniq
2028
+ rescue StandardError
2029
+ [Dir.pwd]
2030
+ end
2031
+
2032
+ def gf_suffixes(ctx)
2033
+ raw = ctx.editor.effective_option("suffixesadd", window: ctx.window, buffer: ctx.buffer).to_s
2034
+ raw.split(",").map(&:strip).reject(&:empty?).map do |s|
2035
+ s.start_with?(".") ? s : ".#{s}"
2036
+ end
2037
+ end
2038
+
2039
+ def expand_gf_path_entry(entry, current_dir)
2040
+ dir = entry.to_s
2041
+ return [current_dir] if dir.empty? || dir == "."
2042
+
2043
+ expanded = File.expand_path(dir, current_dir)
2044
+ if expanded.end_with?("/**")
2045
+ base = expanded[0...-3]
2046
+ [base, *Dir.glob(File.join(base, "**", "*"))].select { |p| File.directory?(p) }
2047
+ elsif expanded.end_with?("**")
2048
+ base = expanded.sub(/\*\*\z/, "")
2049
+ base = base.sub(%r{/+\z}, "")
2050
+ [base, *Dir.glob(File.join(base, "**", "*"))].select { |p| File.directory?(p) }
2051
+ elsif expanded.match?(/[*?\[]/)
2052
+ Dir.glob(expanded).select { |p| File.directory?(p) }
2053
+ else
2054
+ [expanded]
2055
+ end
2056
+ rescue StandardError
2057
+ [expanded || File.expand_path(dir, current_dir)]
2058
+ end
2059
+
1615
2060
  def ex_set_common(ctx, argv, scope:)
1616
2061
  editor = ctx.editor
1617
2062
  if argv.empty?