ruvim 0.2.0 → 0.4.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +96 -0
  4. data/CLAUDE.md +1 -0
  5. data/README.md +15 -1
  6. data/docs/binding.md +39 -0
  7. data/docs/command.md +163 -4
  8. data/docs/config.md +12 -4
  9. data/docs/done.md +21 -0
  10. data/docs/spec.md +214 -18
  11. data/docs/todo.md +1 -5
  12. data/docs/tutorial.md +24 -0
  13. data/docs/vim_diff.md +105 -173
  14. data/lib/ruvim/app.rb +1165 -70
  15. data/lib/ruvim/buffer.rb +47 -1
  16. data/lib/ruvim/cli.rb +18 -3
  17. data/lib/ruvim/clipboard.rb +2 -0
  18. data/lib/ruvim/command_invocation.rb +3 -1
  19. data/lib/ruvim/command_line.rb +2 -0
  20. data/lib/ruvim/command_registry.rb +2 -0
  21. data/lib/ruvim/config_dsl.rb +2 -0
  22. data/lib/ruvim/config_loader.rb +2 -0
  23. data/lib/ruvim/context.rb +2 -0
  24. data/lib/ruvim/dispatcher.rb +143 -13
  25. data/lib/ruvim/display_width.rb +3 -0
  26. data/lib/ruvim/editor.rb +466 -71
  27. data/lib/ruvim/ex_command_registry.rb +2 -0
  28. data/lib/ruvim/file_watcher.rb +243 -0
  29. data/lib/ruvim/git/blame.rb +245 -0
  30. data/lib/ruvim/git/branch.rb +97 -0
  31. data/lib/ruvim/git/commit.rb +102 -0
  32. data/lib/ruvim/git/diff.rb +129 -0
  33. data/lib/ruvim/git/handler.rb +84 -0
  34. data/lib/ruvim/git/log.rb +41 -0
  35. data/lib/ruvim/git/status.rb +103 -0
  36. data/lib/ruvim/global_commands.rb +1066 -105
  37. data/lib/ruvim/highlighter.rb +19 -22
  38. data/lib/ruvim/input.rb +40 -28
  39. data/lib/ruvim/keymap_manager.rb +83 -0
  40. data/lib/ruvim/keyword_chars.rb +2 -0
  41. data/lib/ruvim/lang/base.rb +25 -0
  42. data/lib/ruvim/lang/csv.rb +18 -0
  43. data/lib/ruvim/lang/diff.rb +41 -0
  44. data/lib/ruvim/lang/json.rb +52 -0
  45. data/lib/ruvim/lang/markdown.rb +170 -0
  46. data/lib/ruvim/lang/ruby.rb +236 -0
  47. data/lib/ruvim/lang/scheme.rb +44 -0
  48. data/lib/ruvim/lang/tsv.rb +19 -0
  49. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  50. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  51. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  52. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  53. data/lib/ruvim/rich_view.rb +109 -0
  54. data/lib/ruvim/screen.rb +503 -109
  55. data/lib/ruvim/terminal.rb +18 -1
  56. data/lib/ruvim/text_metrics.rb +2 -0
  57. data/lib/ruvim/version.rb +1 -1
  58. data/lib/ruvim/window.rb +2 -0
  59. data/lib/ruvim.rb +24 -0
  60. data/test/app_completion_test.rb +98 -0
  61. data/test/app_dot_repeat_test.rb +13 -0
  62. data/test/app_motion_test.rb +13 -0
  63. data/test/app_scenario_test.rb +898 -1
  64. data/test/app_startup_test.rb +187 -0
  65. data/test/arglist_test.rb +113 -0
  66. data/test/buffer_test.rb +49 -30
  67. data/test/cli_test.rb +14 -0
  68. data/test/clipboard_test.rb +67 -0
  69. data/test/command_line_test.rb +118 -0
  70. data/test/config_dsl_test.rb +87 -0
  71. data/test/dispatcher_test.rb +322 -0
  72. data/test/display_width_test.rb +41 -0
  73. data/test/editor_register_test.rb +23 -0
  74. data/test/file_watcher_test.rb +197 -0
  75. data/test/follow_test.rb +199 -0
  76. data/test/git_blame_test.rb +713 -0
  77. data/test/highlighter_test.rb +165 -0
  78. data/test/indent_test.rb +287 -0
  79. data/test/input_screen_integration_test.rb +40 -2
  80. data/test/markdown_renderer_test.rb +279 -0
  81. data/test/on_save_hook_test.rb +150 -0
  82. data/test/rich_view_test.rb +734 -0
  83. data/test/screen_test.rb +304 -0
  84. data/test/search_option_test.rb +19 -0
  85. data/test/test_helper.rb +9 -0
  86. metadata +49 -2
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Diff
8
+ module_function
9
+
10
+ # Run git diff with optional extra args.
11
+ # Returns [lines, root, error_message].
12
+ def run(file_path, args: [])
13
+ root, err = Git.repo_root(file_path)
14
+ return [nil, nil, err] unless root
15
+
16
+ cmd = ["git", "diff", *args]
17
+ out, err, status = Open3.capture3(*cmd, chdir: root)
18
+ unless status.success?
19
+ return [nil, nil, err.strip]
20
+ end
21
+ [out.lines(chomp: true), root, nil]
22
+ end
23
+
24
+ # Parse diff output to find file and line number at cursor_y.
25
+ # Returns [filename, line_number] or nil.
26
+ def parse_location(lines, cursor_y)
27
+ return nil if lines.empty? || cursor_y < 0 || cursor_y >= lines.length
28
+
29
+ current_file = nil
30
+ new_line = nil
31
+
32
+ (0..cursor_y).each do |i|
33
+ l = lines[i]
34
+ case l
35
+ when /\Adiff --git a\/.+ b\/(.+)/
36
+ current_file = $1
37
+ when /\A\+\+\+ b\/(.+)/
38
+ current_file = $1
39
+ when /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
40
+ new_line = $1.to_i
41
+ when /\A[ +]/
42
+ # Context or added line: current new_line is this line's number
43
+ new_line += 1 if new_line && i < cursor_y
44
+ end
45
+ # Deleted lines ("-") don't advance new_line
46
+ end
47
+
48
+ return nil unless current_file
49
+
50
+ l = lines[cursor_y]
51
+ case l
52
+ when /\A@@ /
53
+ # On hunk header: new_line already set
54
+ when /\Adiff --git /, /\A---/, /\A\+\+\+/, /\Aindex /
55
+ new_line ||= 1
56
+ when /\A-/
57
+ # Deleted line: point to current new-side position
58
+ end
59
+
60
+ [current_file, new_line || 1]
61
+ end
62
+
63
+ # Command handler methods
64
+ module HandlerMethods
65
+ def git_diff(ctx, argv: [], **)
66
+ file_path = git_resolve_path(ctx)
67
+ unless file_path
68
+ ctx.editor.echo_error("No file or directory to resolve git repo")
69
+ return
70
+ end
71
+
72
+ lines, root, err = Diff.run(file_path, args: argv)
73
+ unless lines
74
+ ctx.editor.echo_error("git diff: #{err}")
75
+ return
76
+ end
77
+
78
+ if lines.empty?
79
+ ctx.editor.echo("No diff output (working tree clean)")
80
+ return
81
+ end
82
+
83
+ buf = ctx.editor.add_virtual_buffer(
84
+ kind: :git_diff,
85
+ name: "[Git Diff]",
86
+ lines: lines,
87
+ filetype: "diff",
88
+ readonly: true,
89
+ modifiable: false
90
+ )
91
+ buf.options["git_repo_root"] = root
92
+ ctx.editor.switch_to_buffer(buf.id)
93
+ bind_git_buffer_keys(ctx.editor, buf.id)
94
+ ctx.editor.echo("[Git Diff]")
95
+ end
96
+
97
+ def git_diff_open_file(ctx, **)
98
+ buf = ctx.buffer
99
+ unless buf.kind == :git_diff || buf.kind == :git_log
100
+ ctx.editor.echo_error("Not a git diff buffer")
101
+ return
102
+ end
103
+
104
+ filename, line_num = Diff.parse_location(buf.lines, ctx.window.cursor_y)
105
+ unless filename
106
+ ctx.editor.echo_error("No file on this line")
107
+ return
108
+ end
109
+
110
+ root = buf.options["git_repo_root"]
111
+ full_path = File.join(root, filename)
112
+ unless File.exist?(full_path)
113
+ ctx.editor.echo_error("File not found: #{filename}")
114
+ return
115
+ end
116
+
117
+ existing = ctx.editor.buffers.values.find { |b| b.path == full_path }
118
+ if existing
119
+ ctx.editor.switch_to_buffer(existing.id)
120
+ else
121
+ new_buf = ctx.editor.add_buffer_from_file(full_path)
122
+ ctx.editor.switch_to_buffer(new_buf.id)
123
+ end
124
+ ctx.editor.current_window.cursor_y = [line_num - 1, 0].max
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module_function
8
+
9
+ # Find git repository root from a file path.
10
+ # Returns [root_path, error_message].
11
+ def repo_root(file_path)
12
+ dir = File.directory?(file_path) ? file_path : File.dirname(file_path)
13
+ out, err, status = Open3.capture3("git", "rev-parse", "--show-toplevel", chdir: dir)
14
+ unless status.success?
15
+ return [nil, err.strip]
16
+ end
17
+ [out.strip, nil]
18
+ end
19
+
20
+ module Handler
21
+ GIT_SUBCOMMANDS = {
22
+ "blame" => :git_blame,
23
+ "blameprev" => :git_blame_prev,
24
+ "blameback" => :git_blame_back,
25
+ "blamecommit" => :git_blame_commit,
26
+ "status" => :git_status,
27
+ "diff" => :git_diff,
28
+ "log" => :git_log,
29
+ "branch" => :git_branch,
30
+ "commit" => :git_commit,
31
+ }.freeze
32
+
33
+ include Blame::HandlerMethods
34
+ include Status::HandlerMethods
35
+ include Diff::HandlerMethods
36
+ include Log::HandlerMethods
37
+ include Branch::HandlerMethods
38
+ include Commit::HandlerMethods
39
+
40
+ def enter_git_command_mode(ctx, **)
41
+ ctx.editor.enter_command_line_mode(":")
42
+ ctx.editor.command_line.replace_text("git ")
43
+ ctx.editor.clear_message
44
+ end
45
+
46
+ def ex_git(ctx, argv: [], **)
47
+ sub = argv.first.to_s.downcase
48
+ if sub.empty?
49
+ ctx.editor.echo("Git subcommands: #{GIT_SUBCOMMANDS.keys.join(', ')}")
50
+ return
51
+ end
52
+
53
+ method = GIT_SUBCOMMANDS[sub]
54
+ unless method
55
+ ctx.editor.echo_error("Unknown Git subcommand: #{sub}")
56
+ return
57
+ end
58
+
59
+ public_send(method, ctx, argv: argv[1..], kwargs: {}, bang: false, count: 1)
60
+ end
61
+
62
+ def git_close_buffer(ctx, **)
63
+ buf_id = ctx.buffer.id
64
+ ctx.editor.git_stream_stop_handler&.call(buf_id)
65
+ ctx.editor.delete_buffer(buf_id)
66
+ end
67
+
68
+ private
69
+
70
+ def git_resolve_path(ctx)
71
+ path = ctx.buffer.path
72
+ return path if path && File.exist?(path)
73
+ dir = Dir.pwd
74
+ File.directory?(dir) ? dir : nil
75
+ end
76
+
77
+ def bind_git_buffer_keys(editor, buffer_id)
78
+ km = editor.keymap_manager
79
+ km.bind_buffer(buffer_id, "\e", "git.close_buffer")
80
+ km.bind_buffer(buffer_id, "<C-c>", "git.close_buffer")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Git
5
+ module Log
6
+ # Command handler methods
7
+ module HandlerMethods
8
+ def git_log(ctx, argv: [], **)
9
+ file_path = git_resolve_path(ctx)
10
+ unless file_path
11
+ ctx.editor.echo_error("No file or directory to resolve git repo")
12
+ return
13
+ end
14
+
15
+ root, err = Git.repo_root(file_path)
16
+ unless root
17
+ ctx.editor.echo_error("git log: #{err}")
18
+ return
19
+ end
20
+
21
+ filetype = argv.include?("-p") ? "diff" : nil
22
+ buf = ctx.editor.add_virtual_buffer(
23
+ kind: :git_log,
24
+ name: "[Git Log]",
25
+ lines: [""],
26
+ filetype: filetype,
27
+ readonly: true,
28
+ modifiable: false
29
+ )
30
+ buf.options["git_repo_root"] = root
31
+ ctx.editor.switch_to_buffer(buf.id)
32
+ bind_git_buffer_keys(ctx.editor, buf.id)
33
+ ctx.editor.echo("[Git Log] loading...")
34
+
35
+ cmd = ["git", "log", *argv]
36
+ ctx.editor.git_stream_handler&.call(buf.id, cmd, root)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Status
8
+ module_function
9
+
10
+ # Run git status.
11
+ # Returns [lines, root, error_message].
12
+ def run(file_path)
13
+ root, err = Git.repo_root(file_path)
14
+ return [nil, nil, err] unless root
15
+
16
+ out, err, status = Open3.capture3("git", "status", chdir: root)
17
+ unless status.success?
18
+ return [nil, nil, err.strip]
19
+ end
20
+ [out.lines(chomp: true), root, nil]
21
+ end
22
+
23
+ # Extract filename from a git status output line.
24
+ # Returns relative path or nil.
25
+ def parse_filename(line)
26
+ stripped = line.to_s.strip
27
+ case stripped
28
+ when /\A(?:modified|new file|deleted|renamed|copied|typechange):\s+(.+)/
29
+ $1.strip
30
+ when /\A(\S.+)/
31
+ # Untracked file lines (no prefix keyword)
32
+ path = $1.strip
33
+ # Skip section headers and hints
34
+ return nil if path.start_with?("(")
35
+ return nil if path.match?(/\A[A-Z]/)
36
+ path
37
+ else
38
+ nil
39
+ end
40
+ end
41
+
42
+ # Command handler methods
43
+ module HandlerMethods
44
+ def git_status(ctx, **)
45
+ file_path = git_resolve_path(ctx)
46
+ unless file_path
47
+ ctx.editor.echo_error("No file or directory to resolve git repo")
48
+ return
49
+ end
50
+
51
+ lines, root, err = Status.run(file_path)
52
+ unless lines
53
+ ctx.editor.echo_error("git status: #{err}")
54
+ return
55
+ end
56
+
57
+ buf = ctx.editor.add_virtual_buffer(
58
+ kind: :git_status,
59
+ name: "[Git Status]",
60
+ lines: lines,
61
+ readonly: true,
62
+ modifiable: false
63
+ )
64
+ buf.options["git_repo_root"] = root
65
+ ctx.editor.switch_to_buffer(buf.id)
66
+ bind_git_buffer_keys(ctx.editor, buf.id)
67
+ ctx.editor.echo("[Git Status]")
68
+ end
69
+
70
+ def git_status_open_file(ctx, **)
71
+ buf = ctx.buffer
72
+ unless buf.kind == :git_status
73
+ ctx.editor.echo_error("Not a git status buffer")
74
+ return
75
+ end
76
+
77
+ line = buf.line_at(ctx.window.cursor_y)
78
+ filename = Status.parse_filename(line)
79
+ unless filename
80
+ ctx.editor.echo_error("No file on this line")
81
+ return
82
+ end
83
+
84
+ root = buf.options["git_repo_root"]
85
+ full_path = File.join(root, filename)
86
+ unless File.exist?(full_path)
87
+ ctx.editor.echo_error("File not found: #{filename}")
88
+ return
89
+ end
90
+
91
+ existing = ctx.editor.buffers.values.find { |b| b.path == full_path }
92
+ if existing
93
+ ctx.editor.switch_to_buffer(existing.id)
94
+ else
95
+ new_buf = ctx.editor.add_buffer_from_file(full_path)
96
+ ctx.editor.switch_to_buffer(new_buf.id)
97
+ end
98
+ end
99
+
100
+ end
101
+ end
102
+ end
103
+ end