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,96 @@
1
+ module RuVim
2
+ module TextMetrics
3
+ module_function
4
+
5
+ Cell = Struct.new(:glyph, :source_col, :display_width, keyword_init: true)
6
+
7
+ # Cursor positions in RuVim are currently "character index" (Ruby String#[] index on UTF-8),
8
+ # not byte offsets. Grapheme-aware movement is layered on top of that.
9
+ def previous_grapheme_char_index(line, char_index)
10
+ idx = [char_index.to_i, 0].max
11
+ return 0 if idx <= 0
12
+
13
+ left = line.to_s[0...idx].to_s
14
+ clusters = left.scan(/\X/)
15
+ return 0 if clusters.empty?
16
+
17
+ idx - clusters.last.length
18
+ end
19
+
20
+ def next_grapheme_char_index(line, char_index)
21
+ s = line.to_s
22
+ idx = [[char_index.to_i, 0].max, s.length].min
23
+ return s.length if idx >= s.length
24
+
25
+ rest = s[idx..].to_s
26
+ m = /\A\X/.match(rest)
27
+ return idx + 1 unless m
28
+
29
+ idx + m[0].length
30
+ end
31
+
32
+ def screen_col_for_char_index(line, char_index, tabstop: 2)
33
+ idx = [char_index.to_i, 0].max
34
+ prefix = line.to_s[0...idx].to_s
35
+ RuVim::DisplayWidth.display_width(prefix, tabstop:)
36
+ end
37
+
38
+ # Returns a character index whose screen column is <= target_screen_col,
39
+ # aligned to a grapheme-cluster boundary.
40
+ def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
41
+ s = line.to_s
42
+ target = [target_screen_col.to_i, 0].max
43
+ screen_col = 0
44
+ char_index = 0
45
+
46
+ s.scan(/\X/).each do |cluster|
47
+ width = RuVim::DisplayWidth.display_width(cluster, tabstop:, start_col: screen_col)
48
+ if screen_col + width > target
49
+ return align == :ceil ? (char_index + cluster.length) : char_index
50
+ end
51
+
52
+ screen_col += width
53
+ char_index += cluster.length
54
+ end
55
+
56
+ char_index
57
+ end
58
+
59
+ def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
60
+ max_width = [width.to_i, 0].max
61
+ cells = []
62
+ display_col = 0
63
+ source_col = source_col_start.to_i
64
+
65
+ text.to_s.each_char do |ch|
66
+ if ch == "\t"
67
+ w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
68
+ break if display_col + w > max_width
69
+
70
+ w.times do
71
+ cells << Cell.new(glyph: " ", source_col:, display_width: 1)
72
+ end
73
+ display_col += w
74
+ source_col += 1
75
+ next
76
+ end
77
+
78
+ w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
79
+ break if display_col + w > max_width
80
+
81
+ cells << Cell.new(glyph: ch, source_col:, display_width: w)
82
+ display_col += w
83
+ source_col += 1
84
+ end
85
+
86
+ [cells, display_col]
87
+ end
88
+
89
+ def pad_plain_to_screen_width(text, width, tabstop: 2)
90
+ cells, used = clip_cells_for_width(text, width, tabstop:)
91
+ out = cells.map(&:glyph).join
92
+ out << (" " * [width.to_i - used, 0].max)
93
+ out
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,71 @@
1
+ module RuVim
2
+ class Window
3
+ attr_reader :id
4
+ attr_accessor :buffer_id, :cursor_x, :cursor_y, :row_offset, :col_offset
5
+ attr_reader :options
6
+
7
+ def initialize(id:, buffer_id:)
8
+ @id = id
9
+ @buffer_id = buffer_id
10
+ @cursor_x = 0
11
+ @cursor_y = 0
12
+ @row_offset = 0
13
+ @col_offset = 0
14
+ @options = {}
15
+ end
16
+
17
+ def clamp_to_buffer(buffer)
18
+ @cursor_y = [[@cursor_y, 0].max, buffer.line_count - 1].min
19
+ @cursor_x = [[@cursor_x, 0].max, buffer.line_length(@cursor_y)].min
20
+ self
21
+ end
22
+
23
+ def move_left(buffer, count = 1)
24
+ count.times do
25
+ break if @cursor_x <= 0
26
+ @cursor_x = RuVim::TextMetrics.previous_grapheme_char_index(buffer.line_at(@cursor_y), @cursor_x)
27
+ end
28
+ clamp_to_buffer(buffer)
29
+ end
30
+
31
+ def move_right(buffer, count = 1)
32
+ count.times do
33
+ line = buffer.line_at(@cursor_y)
34
+ break if @cursor_x >= line.length
35
+ @cursor_x = RuVim::TextMetrics.next_grapheme_char_index(line, @cursor_x)
36
+ end
37
+ clamp_to_buffer(buffer)
38
+ end
39
+
40
+ def move_up(buffer, count = 1)
41
+ @cursor_y -= count
42
+ clamp_to_buffer(buffer)
43
+ end
44
+
45
+ def move_down(buffer, count = 1)
46
+ @cursor_y += count
47
+ clamp_to_buffer(buffer)
48
+ end
49
+
50
+ def ensure_visible(buffer, height:, width:, tabstop: 2)
51
+ clamp_to_buffer(buffer)
52
+
53
+ @row_offset = @cursor_y if @cursor_y < @row_offset
54
+ @row_offset = @cursor_y - height + 1 if @cursor_y >= @row_offset + height
55
+ @row_offset = 0 if @row_offset.negative?
56
+
57
+ line = buffer.line_at(@cursor_y)
58
+ cursor_screen_col = RuVim::TextMetrics.screen_col_for_char_index(line, @cursor_x, tabstop:)
59
+ offset_screen_col = RuVim::TextMetrics.screen_col_for_char_index(line, @col_offset, tabstop:)
60
+
61
+ if cursor_screen_col < offset_screen_col
62
+ @col_offset = RuVim::TextMetrics.char_index_for_screen_col(line, cursor_screen_col, tabstop:)
63
+ elsif cursor_screen_col >= offset_screen_col + width
64
+ target_left = cursor_screen_col - width + 1
65
+ @col_offset = RuVim::TextMetrics.char_index_for_screen_col(line, target_left, tabstop:, align: :ceil)
66
+ end
67
+ @col_offset = 0 if @col_offset.negative?
68
+ end
69
+
70
+ end
71
+ end
data/lib/ruvim.rb ADDED
@@ -0,0 +1,30 @@
1
+ require "singleton"
2
+
3
+ module RuVim
4
+ class Error < StandardError; end
5
+ class CommandError < Error; end
6
+ end
7
+
8
+ require_relative "ruvim/version"
9
+ require_relative "ruvim/command_invocation"
10
+ require_relative "ruvim/display_width"
11
+ require_relative "ruvim/text_metrics"
12
+ require_relative "ruvim/clipboard"
13
+ require_relative "ruvim/highlighter"
14
+ require_relative "ruvim/context"
15
+ require_relative "ruvim/buffer"
16
+ require_relative "ruvim/window"
17
+ require_relative "ruvim/editor"
18
+ require_relative "ruvim/command_registry"
19
+ require_relative "ruvim/ex_command_registry"
20
+ require_relative "ruvim/global_commands"
21
+ require_relative "ruvim/dispatcher"
22
+ require_relative "ruvim/keymap_manager"
23
+ require_relative "ruvim/command_line"
24
+ require_relative "ruvim/input"
25
+ require_relative "ruvim/terminal"
26
+ require_relative "ruvim/screen"
27
+ require_relative "ruvim/config_dsl"
28
+ require_relative "ruvim/config_loader"
29
+ require_relative "ruvim/app"
30
+ require_relative "ruvim/cli"
data/sig/ruvim.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Ruvim
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "test_helper"
2
+
3
+ class AppCompletionTest < Minitest::Test
4
+ def setup
5
+ @app = RuVim::App.new
6
+ @editor = @app.instance_variable_get(:@editor)
7
+ end
8
+
9
+ def test_app_starts_with_intro_buffer_without_path
10
+ assert_equal :intro, @editor.current_buffer.kind
11
+ assert @editor.current_buffer.readonly?
12
+ refute @editor.current_buffer.modifiable?
13
+ end
14
+
15
+ def test_command_line_completion_for_set_option
16
+ @editor.materialize_intro_buffer!
17
+ @editor.enter_command_line_mode(":")
18
+ cmd = @editor.command_line
19
+ cmd.replace_text("set nu")
20
+
21
+ @app.send(:command_line_complete)
22
+
23
+ assert_equal "set number", cmd.text
24
+ end
25
+
26
+ def test_insert_buffer_word_completion_ctrl_n
27
+ @editor.materialize_intro_buffer!
28
+ b = @editor.current_buffer
29
+ b.replace_all_lines!(["fo", "foobar", "fizz"])
30
+ @editor.current_window.cursor_y = 0
31
+ @editor.current_window.cursor_x = 2
32
+ @editor.enter_insert_mode
33
+
34
+ @app.send(:handle_insert_key, :ctrl_n)
35
+
36
+ assert_equal "foobar", b.line_at(0)
37
+ assert_equal 6, @editor.current_window.cursor_x
38
+ end
39
+ end
@@ -0,0 +1,54 @@
1
+ require_relative "test_helper"
2
+
3
+ class AppDotRepeatTest < Minitest::Test
4
+ def setup
5
+ @app = RuVim::App.new(clean: true)
6
+ @editor = @app.instance_variable_get(:@editor)
7
+ @editor.materialize_intro_buffer!
8
+ @buffer = @editor.current_buffer
9
+ @win = @editor.current_window
10
+ end
11
+
12
+ def press(*keys)
13
+ keys.each { |k| @app.send(:handle_normal_key, k) }
14
+ end
15
+
16
+ def test_dot_repeats_x
17
+ @buffer.replace_all_lines!(["abcd"])
18
+ @win.cursor_x = 0
19
+
20
+ press("x")
21
+ press(".")
22
+
23
+ assert_equal "cd", @buffer.line_at(0)
24
+ end
25
+
26
+ def test_dot_repeats_dd
27
+ @buffer.replace_all_lines!(["one", "two", "three"])
28
+
29
+ press("d", "d")
30
+ press(".")
31
+
32
+ assert_equal ["three"], @buffer.lines
33
+ end
34
+
35
+ def test_dot_repeats_paste
36
+ @buffer.replace_all_lines!(["one", "two"])
37
+ press("y", "y")
38
+ press("p")
39
+ press(".")
40
+
41
+ assert_equal ["one", "one", "one", "two"], @buffer.lines
42
+ end
43
+
44
+ def test_dot_repeats_replace_char
45
+ @buffer.replace_all_lines!(["abcd"])
46
+ @win.cursor_x = 0
47
+
48
+ press("r", "x")
49
+ press("l")
50
+ press(".")
51
+
52
+ assert_equal "xxcd", @buffer.line_at(0)
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ require_relative "test_helper"
2
+
3
+ class AppMotionTest < Minitest::Test
4
+ def setup
5
+ @app = RuVim::App.new(clean: true)
6
+ @editor = @app.instance_variable_get(:@editor)
7
+ @editor.materialize_intro_buffer!
8
+ end
9
+
10
+ def press(*keys)
11
+ keys.each { |k| @app.send(:handle_normal_key, k) }
12
+ end
13
+
14
+ def test_find_char_and_repeat
15
+ b = @editor.current_buffer
16
+ b.replace_all_lines!(["abcabc"])
17
+ @editor.current_window.cursor_y = 0
18
+ @editor.current_window.cursor_x = 0
19
+
20
+ press("f", "c")
21
+ assert_equal 2, @editor.current_window.cursor_x
22
+
23
+ press(";")
24
+ assert_equal 5, @editor.current_window.cursor_x
25
+
26
+ press(",")
27
+ assert_equal 2, @editor.current_window.cursor_x
28
+ end
29
+
30
+ def test_till_char_moves_before_match
31
+ b = @editor.current_buffer
32
+ b.replace_all_lines!(["abcabc"])
33
+ @editor.current_window.cursor_y = 0
34
+ @editor.current_window.cursor_x = 0
35
+
36
+ press("t", "c")
37
+
38
+ assert_equal 1, @editor.current_window.cursor_x
39
+ end
40
+
41
+ def test_match_bracket_with_percent
42
+ b = @editor.current_buffer
43
+ b.replace_all_lines!(["x(a[b]c)d"])
44
+ @editor.current_window.cursor_y = 0
45
+ @editor.current_window.cursor_x = 1 # (
46
+
47
+ press("%")
48
+ assert_equal 7, @editor.current_window.cursor_x
49
+
50
+ press("%")
51
+ assert_equal 1, @editor.current_window.cursor_x
52
+ end
53
+
54
+ def test_pageup_and_pagedown_move_by_visible_page_height
55
+ b = @editor.current_buffer
56
+ b.replace_all_lines!((1..20).map { |i| "line#{i}" })
57
+ @editor.current_window.cursor_y = 0
58
+ @editor.current_window.cursor_x = 0
59
+
60
+ screen = @app.instance_variable_get(:@screen)
61
+ screen.define_singleton_method(:current_window_view_height) { |_editor| 5 }
62
+
63
+ @app.send(:handle_normal_key, :pagedown)
64
+ assert_equal 4, @editor.current_window.cursor_y
65
+
66
+ @editor.pending_count = 2
67
+ @app.send(:handle_normal_key, :pagedown)
68
+ assert_equal 12, @editor.current_window.cursor_y
69
+
70
+ @app.send(:handle_normal_key, :pageup)
71
+ assert_equal 8, @editor.current_window.cursor_y
72
+ end
73
+ end
@@ -0,0 +1,47 @@
1
+ require_relative "test_helper"
2
+
3
+ class AppRegisterTest < Minitest::Test
4
+ def setup
5
+ @app = RuVim::App.new(clean: true)
6
+ @editor = @app.instance_variable_get(:@editor)
7
+ @editor.materialize_intro_buffer!
8
+ @buffer = @editor.current_buffer
9
+ end
10
+
11
+ def press(*keys)
12
+ keys.each { |k| @app.send(:handle_normal_key, k) }
13
+ end
14
+
15
+ def test_yy_updates_register_zero
16
+ @buffer.replace_all_lines!(["alpha", "beta"])
17
+
18
+ press("y", "y")
19
+
20
+ assert_equal({ text: "alpha\n", type: :linewise }, @editor.get_register("0"))
21
+ assert_equal({ text: "alpha\n", type: :linewise }, @editor.get_register("\""))
22
+ end
23
+
24
+ def test_dd_rotates_numbered_registers
25
+ @buffer.replace_all_lines!(["one", "two", "three"])
26
+
27
+ press("d", "d")
28
+ assert_equal({ text: "one\n", type: :linewise }, @editor.get_register("1"))
29
+
30
+ press("d", "d")
31
+ assert_equal({ text: "two\n", type: :linewise }, @editor.get_register("1"))
32
+ assert_equal({ text: "one\n", type: :linewise }, @editor.get_register("2"))
33
+ end
34
+
35
+ def test_black_hole_register_does_not_change_unnamed_or_numbered
36
+ @buffer.replace_all_lines!(["one", "two"])
37
+
38
+ press("y", "y")
39
+ seed = @editor.get_register("\"")
40
+
41
+ press("\"", "_", "d", "d")
42
+
43
+ assert_equal seed, @editor.get_register("\"")
44
+ assert_equal({ text: "one\n", type: :linewise }, @editor.get_register("0"))
45
+ assert_nil @editor.get_register("1")
46
+ end
47
+ end
@@ -0,0 +1,77 @@
1
+ require_relative "test_helper"
2
+
3
+ class AppScenarioTest < Minitest::Test
4
+ def setup
5
+ @app = RuVim::App.new(clean: true)
6
+ @editor = @app.instance_variable_get(:@editor)
7
+ @editor.materialize_intro_buffer!
8
+ end
9
+
10
+ def feed(*keys)
11
+ keys.each { |k| @app.send(:handle_key, k) }
12
+ end
13
+
14
+ def test_insert_edit_search_and_delete_scenario
15
+ feed("i", "h", "e", "l", "l", "o", :enter, "w", "o", "r", "l", "d", :escape)
16
+ feed("k", "0", "x")
17
+ feed("/", "o", :enter)
18
+ feed("n")
19
+
20
+ assert_equal ["ello", "world"], @editor.current_buffer.lines
21
+ assert_equal :normal, @editor.mode
22
+ assert_equal "Search wrapped", @editor.message if @editor.message == "Search wrapped"
23
+ assert_operator @editor.current_window.cursor_y, :>=, 0
24
+ end
25
+
26
+ def test_visual_block_yank
27
+ @editor.current_buffer.replace_all_lines!(["abcde", "ABCDE", "xyz"])
28
+ @editor.current_window.cursor_x = 1
29
+ @editor.current_window.cursor_y = 0
30
+
31
+ feed(:ctrl_v, "j", "l", "l", "y")
32
+
33
+ reg = @editor.get_register("\"")
34
+ assert_equal :normal, @editor.mode
35
+ assert_equal "bcd\nBCD", reg[:text]
36
+ end
37
+
38
+ def test_visual_block_delete
39
+ @editor.current_buffer.replace_all_lines!(["abcde", "ABCDE", "xyz"])
40
+ @editor.current_window.cursor_x = 1
41
+ @editor.current_window.cursor_y = 0
42
+
43
+ feed(:ctrl_v, "j", "l", "l", "d")
44
+
45
+ assert_equal ["ae", "AE", "xyz"], @editor.current_buffer.lines
46
+ assert_equal 0, @editor.current_window.cursor_y
47
+ assert_equal 1, @editor.current_window.cursor_x
48
+ assert_equal :normal, @editor.mode
49
+ end
50
+
51
+ def test_dot_repeats_insert_change
52
+ @editor.current_buffer.replace_all_lines!([""])
53
+ feed("i", "a", "b", :escape)
54
+ feed(".")
55
+
56
+ assert_equal ["abab"], @editor.current_buffer.lines
57
+ end
58
+
59
+ def test_dot_repeats_change_with_text_object
60
+ @editor.current_buffer.replace_all_lines!(["foo bar"])
61
+ feed("c", "i", "w", "X", :escape)
62
+ feed("w")
63
+ feed(".")
64
+
65
+ assert_equal ["X X"], @editor.current_buffer.lines
66
+ end
67
+
68
+ def test_dot_repeat_works_inside_macro
69
+ @editor.current_buffer.replace_all_lines!(["abc", "abc", "abc"])
70
+
71
+ feed("x")
72
+ feed("j", "0", "q", "a", ".", "j", "q")
73
+ feed("@", "a")
74
+
75
+ assert_equal ["bc", "bc", "bc"], @editor.current_buffer.lines
76
+ end
77
+ end