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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "open3"
5
+
6
+ module RuVim
7
+ module Lang
8
+ module Ruby
9
+ PRISM_KEYWORD_TYPES = %i[
10
+ KEYWORD_ALIAS
11
+ KEYWORD_AND
12
+ KEYWORD_BEGIN
13
+ KEYWORD_BEGIN_UPCASE
14
+ KEYWORD_BREAK
15
+ KEYWORD_CASE
16
+ KEYWORD_CLASS
17
+ KEYWORD_DEF
18
+ KEYWORD_DEFINED
19
+ KEYWORD_DO
20
+ KEYWORD_ELSE
21
+ KEYWORD_ELSIF
22
+ KEYWORD_END
23
+ KEYWORD_ENSURE
24
+ KEYWORD_FALSE
25
+ KEYWORD_FOR
26
+ KEYWORD_IF
27
+ KEYWORD_IF_MODIFIER
28
+ KEYWORD_IN
29
+ KEYWORD_MODULE
30
+ KEYWORD_NEXT
31
+ KEYWORD_NIL
32
+ KEYWORD_NOT
33
+ KEYWORD_OR
34
+ KEYWORD_REDO
35
+ KEYWORD_RESCUE
36
+ KEYWORD_RESCUE_MODIFIER
37
+ KEYWORD_RETRY
38
+ KEYWORD_RETURN
39
+ KEYWORD_SELF
40
+ KEYWORD_SUPER
41
+ KEYWORD_THEN
42
+ KEYWORD_TRUE
43
+ KEYWORD_UNDEF
44
+ KEYWORD_UNLESS
45
+ KEYWORD_UNLESS_MODIFIER
46
+ KEYWORD_UNTIL
47
+ KEYWORD_UNTIL_MODIFIER
48
+ KEYWORD_WHEN
49
+ KEYWORD_WHILE
50
+ KEYWORD_WHILE_MODIFIER
51
+ KEYWORD_YIELD
52
+ MISSING
53
+ ].freeze
54
+
55
+ PRISM_STRING_TYPES = %i[
56
+ STRING_BEGIN
57
+ STRING_CONTENT
58
+ STRING_END
59
+ SYMBOL_BEGIN
60
+ REGEXP_BEGIN
61
+ REGEXP_CONTENT
62
+ REGEXP_END
63
+ XSTRING_BEGIN
64
+ XSTRING_CONTENT
65
+ XSTRING_END
66
+ WORDS_BEGIN
67
+ QWORDS_BEGIN
68
+ WORDS_SEPARATOR
69
+ STRING
70
+ CHARACTER_LITERAL
71
+ ].freeze
72
+
73
+ PRISM_NUMBER_TYPES = %i[
74
+ INTEGER
75
+ FLOAT
76
+ RATIONAL_NUMBER
77
+ IMAGINARY_NUMBER
78
+ UINTEGER
79
+ ].freeze
80
+
81
+ PRISM_COMMENT_TYPES = %i[
82
+ COMMENT
83
+ EMBDOC_BEGIN
84
+ EMBDOC_LINE
85
+ EMBDOC_END
86
+ ].freeze
87
+
88
+ PRISM_VARIABLE_TYPES = %i[
89
+ INSTANCE_VARIABLE
90
+ CLASS_VARIABLE
91
+ GLOBAL_VARIABLE
92
+ ].freeze
93
+
94
+ PRISM_CONSTANT_TYPES = %i[
95
+ CONSTANT
96
+ ].freeze
97
+
98
+ # Keywords that open a new indentation level
99
+ INDENT_OPEN_RE = /\A\s*(?:def|class|module|if|unless|while|until|for|begin|case)\b/
100
+ # `do` at end of line (block form)
101
+ INDENT_DO_RE = /\bdo\s*(\|[^|]*\|)?\s*$/
102
+ # Opening brackets at end of line
103
+ INDENT_BRACKET_OPEN_RE = /[\[({]\s*$/
104
+ # Keywords that close an indentation level
105
+ INDENT_CLOSE_RE = /\A\s*(?:end|[}\])])/
106
+ # Keywords at same level as their opening keyword (dedent for the line itself)
107
+ INDENT_MID_RE = /\A\s*(?:else|elsif|when|rescue|ensure|in)\b/
108
+ # Modifier keywords that do NOT open indentation
109
+ MODIFIER_RE = /\b(?:if|unless|while|until|rescue)\b/
110
+
111
+ module_function
112
+
113
+ def calculate_indent(lines, target_row, shiftwidth)
114
+ depth = 0
115
+ (0...target_row).each do |row|
116
+ line = lines[row]
117
+ stripped = line.to_s.lstrip
118
+
119
+ # Skip blank and comment lines for indent computation
120
+ next if stripped.empty? || stripped.start_with?("#")
121
+
122
+ # Check if the line opens a new level
123
+ if stripped.match?(INDENT_OPEN_RE)
124
+ # Check for modifier form: something before the keyword on the same line
125
+ # e.g. "return if true" — if keyword is not at start, it's a modifier
126
+ first_word = stripped[/\A(\w+)/, 1]
127
+ if %w[if unless while until rescue].include?(first_word)
128
+ depth += 1
129
+ elsif %w[def class module begin case for].include?(first_word)
130
+ depth += 1
131
+ end
132
+ elsif stripped.match?(INDENT_DO_RE)
133
+ depth += 1
134
+ end
135
+
136
+ # Count opening brackets (not at end-of-line pattern, but individual)
137
+ stripped.each_char do |ch|
138
+ case ch
139
+ when "{", "[", "("
140
+ depth += 1
141
+ when "}", "]", ")"
142
+ depth -= 1
143
+ end
144
+ end if !stripped.match?(INDENT_OPEN_RE) && !stripped.match?(INDENT_DO_RE)
145
+
146
+ # Handle closing keywords
147
+ if stripped.match?(/\A\s*end\b/)
148
+ depth -= 1
149
+ end
150
+
151
+ # Handle mid keywords (else/elsif/when/rescue/ensure) — they don't change depth for following lines
152
+ end
153
+
154
+ # Now compute indent for target_row
155
+ target_line = lines[target_row].to_s.lstrip
156
+
157
+ # If target line is a closing keyword, dedent
158
+ if target_line.match?(INDENT_CLOSE_RE)
159
+ depth -= 1
160
+ elsif target_line.match?(INDENT_MID_RE)
161
+ depth -= 1
162
+ end
163
+
164
+ depth = 0 if depth < 0
165
+ depth * shiftwidth
166
+ end
167
+
168
+ # Returns true if the line should increase indent for the next line
169
+ def indent_trigger?(line)
170
+ stripped = line.to_s.rstrip.lstrip
171
+ first_word = stripped[/\A(\w+)/, 1].to_s
172
+ return true if %w[def class module if unless while until for begin case].include?(first_word)
173
+ return true if stripped.match?(/\bdo\s*(\|[^|]*\|)?\s*$/)
174
+ false
175
+ end
176
+
177
+ # Dedent trigger patterns keyed by the last character typed
178
+ DEDENT_TRIGGERS = {
179
+ "d" => /\A(\s*)end\z/,
180
+ "e" => /\A(\s*)(?:else|rescue|ensure)\z/,
181
+ "f" => /\A(\s*)elsif\z/,
182
+ "n" => /\A(\s*)(?:when|in)\z/
183
+ }.freeze
184
+
185
+ # Returns the dedent pattern for the given character, or nil
186
+ def dedent_trigger(char)
187
+ DEDENT_TRIGGERS[char]
188
+ end
189
+
190
+ def on_save(ctx, path)
191
+ return unless path && File.exist?(path)
192
+ output, status = Open3.capture2e("ruby", "-wc", path)
193
+ message = output.sub(/^Syntax OK\n?\z/m, "").strip
194
+
195
+ if !status.success? || !message.empty?
196
+ buffer_id = ctx.buffer.id
197
+ items = message.lines.filter_map { |line|
198
+ if line =~ /\A.+?:(\d+):/
199
+ { buffer_id: buffer_id, row: $1.to_i - 1, col: 0, text: line.strip }
200
+ end
201
+ }
202
+ items = [{ buffer_id: buffer_id, row: 0, col: 0, text: message }] if items.empty?
203
+ ctx.editor.set_quickfix_list(items)
204
+ first = message.lines.first.to_s.strip
205
+ hint = items.size > 1 ? " (Q to see all, #{items.size} total)" : ""
206
+ ctx.editor.echo_error("#{first}#{hint}")
207
+ else
208
+ ctx.editor.set_quickfix_list([])
209
+ end
210
+ end
211
+
212
+ def color_columns(text)
213
+ cols = {}
214
+ Prism.lex(text).value.each do |entry|
215
+ token = entry[0]
216
+ type = token.type
217
+ range = token.location.start_offset...token.location.end_offset
218
+ if PRISM_STRING_TYPES.include?(type)
219
+ range.each { |idx| cols[idx] = Highlighter::STRING_COLOR unless cols.key?(idx) }
220
+ elsif PRISM_KEYWORD_TYPES.include?(type)
221
+ range.each { |idx| cols[idx] = Highlighter::KEYWORD_COLOR unless cols.key?(idx) }
222
+ elsif PRISM_NUMBER_TYPES.include?(type)
223
+ range.each { |idx| cols[idx] = Highlighter::NUMBER_COLOR unless cols.key?(idx) }
224
+ elsif PRISM_VARIABLE_TYPES.include?(type)
225
+ range.each { |idx| cols[idx] = Highlighter::VARIABLE_COLOR unless cols.key?(idx) }
226
+ elsif PRISM_CONSTANT_TYPES.include?(type)
227
+ range.each { |idx| cols[idx] = Highlighter::CONSTANT_COLOR unless cols.key?(idx) }
228
+ elsif PRISM_COMMENT_TYPES.include?(type)
229
+ range.each { |idx| cols[idx] = Highlighter::COMMENT_COLOR }
230
+ end
231
+ end
232
+ cols
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Scheme
6
+ KEYWORDS = %w[
7
+ define define-syntax define-record-type define-values define-library
8
+ lambda let let* letrec letrec* let-values let-syntax letrec-syntax
9
+ if cond case when unless and or not
10
+ begin do set! quote quasiquote unquote unquote-splicing
11
+ syntax-rules syntax-case
12
+ import export library
13
+ else =>
14
+ call-with-current-continuation call/cc
15
+ call-with-values values
16
+ dynamic-wind
17
+ guard raise raise-continuable with-exception-handler
18
+ parameterize make-parameter
19
+ include include-ci
20
+ ].freeze
21
+
22
+ KEYWORD_RE = /(?<=[\s(])(#{KEYWORDS.map { |k| Regexp.escape(k) }.join("|")})(?=[\s()\]]|$)/
23
+ COMMENT_RE = /;.*/
24
+ STRING_RE = /"(?:\\.|[^"\\])*"/
25
+ CHAR_RE = /#\\(?:space|newline|tab|alarm|backspace|delete|escape|null|return|[^\s()])/
26
+ BOOLEAN_RE = /#[tf]\b/
27
+ NUMBER_RE = /(?<=[\s(])[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?=[\s()\]]|$)/
28
+
29
+ module_function
30
+
31
+ def color_columns(text)
32
+ cols = {}
33
+ # Order matters: comment overrides all, then strings, then others
34
+ Highlighter.apply_regex(cols, text, CHAR_RE, Highlighter::STRING_COLOR)
35
+ Highlighter.apply_regex(cols, text, STRING_RE, Highlighter::STRING_COLOR)
36
+ Highlighter.apply_regex(cols, text, KEYWORD_RE, Highlighter::KEYWORD_COLOR)
37
+ Highlighter.apply_regex(cols, text, BOOLEAN_RE, "\e[35m")
38
+ Highlighter.apply_regex(cols, text, NUMBER_RE, Highlighter::NUMBER_COLOR)
39
+ Highlighter.apply_regex(cols, text, COMMENT_RE, Highlighter::COMMENT_COLOR, override: true)
40
+ cols
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Tsv
6
+ module_function
7
+
8
+ # Detect TSV from buffer content: tabs >= commas and tabs > 0
9
+ def detect?(buffer)
10
+ sample = (0...[buffer.line_count, 20].min).map { |i| buffer.line_at(i) }
11
+ tabs = sample.sum { |l| l.count("\t") }
12
+ commas = sample.sum { |l| l.count(",") }
13
+ tabs > 0 && tabs >= commas
14
+ end
15
+ end
16
+ end
17
+
18
+ RichView.register("tsv", RichView::TableRenderer, detector: Lang::Tsv.method(:detect?))
19
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RuVim
6
+ module RichView
7
+ module JsonRenderer
8
+ module_function
9
+
10
+ # Signal that this renderer creates a virtual buffer
11
+ # instead of entering rich mode on the same buffer.
12
+ def open_view!(editor)
13
+ buffer = editor.current_buffer
14
+ window = editor.current_window
15
+ text = buffer.lines.join("\n")
16
+
17
+ begin
18
+ parsed = JSON.parse(text)
19
+ rescue JSON::ParserError => e
20
+ editor.echo_error("JSON parse error: #{e.message}")
21
+ return
22
+ end
23
+
24
+ # Compute cursor's significant char offset before formatting
25
+ cursor_offset = char_offset_for(buffer.lines, window.cursor_y, window.cursor_x)
26
+ sig_count = significant_char_count(text, cursor_offset + 1)
27
+
28
+ formatted = JSON.pretty_generate(parsed)
29
+ lines = formatted.lines(chomp: true)
30
+
31
+ target_line = line_for_significant_count(formatted, sig_count)
32
+
33
+ buf = editor.add_virtual_buffer(
34
+ kind: :json_formatted,
35
+ name: "[JSON Formatted]",
36
+ lines: lines,
37
+ filetype: "json",
38
+ readonly: true,
39
+ modifiable: false
40
+ )
41
+ editor.switch_to_buffer(buf.id)
42
+ RichView.bind_close_keys(editor, buf.id)
43
+ window.cursor_y = [target_line, lines.length - 1].min
44
+ window.cursor_x = 0
45
+ editor.echo("[JSON Formatted] #{lines.length} lines")
46
+ end
47
+
48
+ # Count significant (non-whitespace-outside-strings) characters
49
+ # in text[0...byte_offset].
50
+ def significant_char_count(text, byte_offset)
51
+ in_string = false
52
+ escape = false
53
+ count = 0
54
+ text.each_char.with_index do |ch, i|
55
+ break if i >= byte_offset
56
+ if in_string
57
+ if escape
58
+ escape = false
59
+ elsif ch == "\\"
60
+ escape = true
61
+ elsif ch == '"'
62
+ in_string = false
63
+ end
64
+ count += 1
65
+ else
66
+ case ch
67
+ when '"'
68
+ in_string = true
69
+ count += 1
70
+ when " ", "\n", "\r", "\t"
71
+ # skip whitespace outside strings
72
+ else
73
+ count += 1
74
+ end
75
+ end
76
+ end
77
+ count
78
+ end
79
+
80
+ # Find the line number in text where the N-th significant character falls.
81
+ def line_for_significant_count(text, target_count)
82
+ return 0 if target_count <= 0
83
+
84
+ in_string = false
85
+ escape = false
86
+ count = 0
87
+ line = 0
88
+ text.each_char do |ch|
89
+ if ch == "\n" && !in_string
90
+ line += 1
91
+ next
92
+ end
93
+ if in_string
94
+ if escape
95
+ escape = false
96
+ elsif ch == "\\"
97
+ escape = true
98
+ elsif ch == '"'
99
+ in_string = false
100
+ end
101
+ count += 1
102
+ else
103
+ case ch
104
+ when '"'
105
+ in_string = true
106
+ count += 1
107
+ when " ", "\r", "\t"
108
+ # skip
109
+ else
110
+ count += 1
111
+ end
112
+ end
113
+ return line if count >= target_count
114
+ end
115
+ line
116
+ end
117
+
118
+ # Compute character offset in joined text from cursor row/col.
119
+ def char_offset_for(lines, row, col)
120
+ offset = 0
121
+ lines.each_with_index do |line, i|
122
+ if i == row
123
+ return offset + [col, line.length].min
124
+ end
125
+ offset += line.length + 1 # +1 for "\n"
126
+ end
127
+ offset
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RuVim
6
+ module RichView
7
+ module JsonlRenderer
8
+ module_function
9
+
10
+ SEPARATOR = "---"
11
+
12
+ def open_view!(editor)
13
+ buffer = editor.current_buffer
14
+ window = editor.current_window
15
+ cursor_row = window.cursor_y
16
+
17
+ output_lines = []
18
+ # Map from source line index to starting line in output
19
+ line_map = {}
20
+
21
+ buffer.lines.each_with_index do |raw_line, idx|
22
+ line = raw_line.to_s.strip
23
+ next if line.empty?
24
+
25
+ output_lines << SEPARATOR unless output_lines.empty?
26
+ line_map[idx] = output_lines.length
27
+
28
+ begin
29
+ parsed = JSON.parse(line)
30
+ formatted = JSON.pretty_generate(parsed)
31
+ formatted.each_line(chomp: true) { |l| output_lines << l }
32
+ rescue JSON::ParserError
33
+ output_lines << "// PARSE ERROR: #{raw_line}"
34
+ end
35
+ end
36
+
37
+ output_lines << "" if output_lines.empty?
38
+
39
+ target_line = line_map[cursor_row] || 0
40
+
41
+ buf = editor.add_virtual_buffer(
42
+ kind: :jsonl_formatted,
43
+ name: "[JSONL Formatted]",
44
+ lines: output_lines,
45
+ filetype: "json",
46
+ readonly: true,
47
+ modifiable: false
48
+ )
49
+ editor.switch_to_buffer(buf.id)
50
+ RichView.bind_close_keys(editor, buf.id)
51
+ window.cursor_y = [target_line, output_lines.length - 1].min
52
+ window.cursor_x = 0
53
+ editor.echo("[JSONL Formatted] #{output_lines.length} lines")
54
+ end
55
+ end
56
+ end
57
+ end