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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module RichView
5
+ module MarkdownRenderer
6
+ module_function
7
+
8
+ def delimiter_for(_format)
9
+ nil
10
+ end
11
+
12
+ def needs_pre_context?
13
+ true
14
+ end
15
+
16
+ def render_visible(lines, delimiter:, context: {})
17
+ return lines if lines.nil? || lines.empty?
18
+
19
+ state = Lang::Markdown::FenceState.new
20
+ pre = context[:pre_context_lines]
21
+ if pre
22
+ pre.each { |l| state.scan_line(l) }
23
+ end
24
+
25
+ # Identify table groups for column-width alignment
26
+ table_groups = identify_table_groups(lines)
27
+
28
+ lines.each_with_index.map do |line, idx|
29
+ rendered = render_line(line, state, table_groups[idx])
30
+ state.scan_line(line)
31
+ rendered
32
+ end
33
+ end
34
+
35
+ def cursor_display_col(raw_line, cursor_x, visible_lines:, delimiter:)
36
+ if Lang::Markdown.table_line?(raw_line)
37
+ table_lines = visible_lines.select { |l| Lang::Markdown.table_line?(l) }
38
+ group = build_table_group(table_lines)
39
+ if group
40
+ return table_cursor_display_col(raw_line, cursor_x, group)
41
+ end
42
+ end
43
+ RuVim::TextMetrics.screen_col_for_char_index(raw_line, cursor_x)
44
+ end
45
+
46
+ # --- Line rendering ---
47
+
48
+ def render_line(line, state, table_group)
49
+ stripped = line.to_s.strip
50
+
51
+ # Code fence open/close
52
+ if !state.in_code_block && Lang::Markdown.fence_line?(stripped)
53
+ return "\e[90m#{line}\e[m"
54
+ end
55
+
56
+ if state.in_code_block
57
+ if Lang::Markdown.fence_line?(stripped)
58
+ return "\e[90m#{line}\e[m"
59
+ end
60
+ return "\e[38;5;223m#{line}\e[m"
61
+ end
62
+
63
+ # HR
64
+ if Lang::Markdown.horizontal_rule?(stripped)
65
+ return render_hr(line)
66
+ end
67
+
68
+ # Heading
69
+ level = Lang::Markdown.heading_level(line)
70
+ if level > 0
71
+ return render_heading(line, level)
72
+ end
73
+
74
+ # Block quote
75
+ if Lang::Markdown.block_quote?(line)
76
+ return "\e[36m#{apply_inline(line)}\e[m"
77
+ end
78
+
79
+ # Table
80
+ if table_group
81
+ return render_table_line(line, stripped, table_group)
82
+ end
83
+
84
+ # Default: inline decoration only
85
+ apply_inline(line)
86
+ end
87
+
88
+ # --- Heading rendering ---
89
+
90
+ HEADING_STYLES = Lang::Markdown::HEADING_COLORS
91
+
92
+ def render_heading(line, level)
93
+ style = HEADING_STYLES[level] || HEADING_STYLES[6]
94
+ "#{style}#{line}\e[m"
95
+ end
96
+
97
+ # --- HR rendering ---
98
+
99
+ def render_hr(line)
100
+ # Replace the HR marker with box-drawing horizontal line
101
+ width = [line.length, 3].max
102
+ "\e[90m#{"─" * width}\e[m"
103
+ end
104
+
105
+ # --- Inline decoration ---
106
+
107
+ def apply_inline(line)
108
+ result = line.to_s.dup
109
+
110
+ # Checkbox (must come before bold/italic to avoid interference)
111
+ result = result.gsub(Lang::Markdown::CHECKBOX_CHECKED_RE) { "#{$1}\e[32m[x]\e[m" }
112
+ result = result.gsub(Lang::Markdown::CHECKBOX_UNCHECKED_RE) { "#{$1}\e[90m[ ]\e[m" }
113
+
114
+ # Bold **text**
115
+ result = result.gsub(Lang::Markdown::BOLD_RE) { "\e[1m**#{$1}**\e[22m" }
116
+
117
+ # Italic *text* (but not ** which is bold)
118
+ result = result.gsub(Lang::Markdown::ITALIC_RE) { "\e[3m*#{$1}*\e[23m" }
119
+
120
+ # Inline code `text`
121
+ result = result.gsub(Lang::Markdown::INLINE_CODE_RE) { "\e[33m`#{$1}`\e[m" }
122
+
123
+ # Links [text](url)
124
+ result = result.gsub(Lang::Markdown::LINK_RE) { "\e[4m#{$1}\e[24m(\e[2m#{$2}\e[22m)" }
125
+
126
+ result
127
+ end
128
+
129
+ # --- Table rendering ---
130
+
131
+ def identify_table_groups(lines)
132
+ groups = {}
133
+ i = 0
134
+ while i < lines.length
135
+ if Lang::Markdown.table_line?(lines[i])
136
+ start = i
137
+ table_lines = []
138
+ while i < lines.length && Lang::Markdown.table_line?(lines[i])
139
+ table_lines << lines[i]
140
+ i += 1
141
+ end
142
+ group = build_table_group(table_lines)
143
+ (start...(start + table_lines.length)).each { |j| groups[j] = group }
144
+ else
145
+ i += 1
146
+ end
147
+ end
148
+ groups
149
+ end
150
+
151
+ def build_table_group(table_lines)
152
+ return nil if table_lines.nil? || table_lines.empty?
153
+
154
+ cells = table_lines.map { |l| Lang::Markdown.parse_table_cells(l) }
155
+ max_cols = cells.map(&:length).max
156
+ return nil if max_cols.nil? || max_cols.zero?
157
+
158
+ col_widths = Array.new(max_cols, 0)
159
+ cells.each do |row|
160
+ row.each_with_index do |cell, ci|
161
+ w = RuVim::DisplayWidth.display_width(cell)
162
+ col_widths[ci] = w if w > col_widths[ci]
163
+ end
164
+ end
165
+
166
+ { col_widths: col_widths, max_cols: max_cols }
167
+ end
168
+
169
+ def render_table_line(line, stripped, group)
170
+ if Lang::Markdown.table_separator?(stripped)
171
+ render_table_separator(group)
172
+ else
173
+ render_table_data_row(line, group)
174
+ end
175
+ end
176
+
177
+ def render_table_separator(group)
178
+ col_widths = group[:col_widths]
179
+ cells = col_widths.map { |w| "─" * (w + 2) }
180
+ "├#{cells.join("┼")}┤"
181
+ end
182
+
183
+ def render_table_data_row(line, group)
184
+ col_widths = group[:col_widths]
185
+ cells = Lang::Markdown.parse_table_cells(line)
186
+ padded = cells.each_with_index.map do |cell, i|
187
+ target = col_widths[i] || 0
188
+ pad_cell(cell, target)
189
+ end
190
+ # Fill missing columns
191
+ (cells.length...group[:max_cols]).each do |i|
192
+ padded << " " * (col_widths[i] || 0)
193
+ end
194
+ "│ #{padded.join(" │ ")} │"
195
+ end
196
+
197
+ def pad_cell(cell, target_width)
198
+ current = RuVim::DisplayWidth.display_width(cell)
199
+ gap = target_width - current
200
+ gap > 0 ? "#{cell}#{" " * gap}" : cell
201
+ end
202
+
203
+ # --- Table cursor mapping ---
204
+
205
+ def table_cursor_display_col(raw_line, cursor_x, group)
206
+ col_widths = group[:col_widths]
207
+ cells = Lang::Markdown.parse_table_cells(raw_line)
208
+
209
+ # Map cursor_x (in raw line) to display col in formatted line
210
+ # Raw line: "| cell1 | cell2 |"
211
+ # Formatted: "│ cell1 │ cell2 │"
212
+
213
+ # Walk through the raw line to find which cell cursor_x is in
214
+ pos = 0
215
+ raw = raw_line.to_s
216
+ # Skip leading whitespace
217
+ pos += 1 while pos < raw.length && raw[pos] == " "
218
+ # Skip leading |
219
+ pos += 1 if pos < raw.length && raw[pos] == "|"
220
+
221
+ display_col = 2 # "│ " prefix
222
+ cells.each_with_index do |cell, ci|
223
+ # Skip whitespace before cell content
224
+ pos += 1 while pos < raw.length && raw[pos] == " "
225
+
226
+ cell_start = pos
227
+ cell_end = cell_start + cell.length
228
+
229
+ if cursor_x < cell_end || ci == cells.length - 1
230
+ # Cursor is in this cell
231
+ offset_in_cell = [cursor_x - cell_start, 0].max
232
+ offset_in_cell = [offset_in_cell, cell.length].min
233
+ return display_col + RuVim::DisplayWidth.display_width(cell[0...offset_in_cell])
234
+ end
235
+
236
+ display_col += (col_widths[ci] || 0) + 3 # " │ "
237
+ pos = cell_end
238
+ # Skip whitespace after cell
239
+ pos += 1 while pos < raw.length && raw[pos] == " "
240
+ # Skip |
241
+ pos += 1 if pos < raw.length && raw[pos] == "|"
242
+ end
243
+
244
+ display_col
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module RichView
5
+ module TableRenderer
6
+ SEPARATOR = " | "
7
+ SEPARATOR_WIDTH = 3
8
+
9
+ module_function
10
+
11
+ def delimiter_for(format)
12
+ case format.to_s
13
+ when "csv" then ","
14
+ when "tsv" then "\t"
15
+ else "\t"
16
+ end
17
+ end
18
+
19
+ # Compute max display width per column from visible lines.
20
+ # Returns an Array of column widths, or nil if single-column / empty.
21
+ def compute_col_widths(lines, delimiter:)
22
+ return nil if lines.nil? || lines.empty?
23
+
24
+ rows = lines.map { |line| split_fields(line, delimiter) }
25
+ max_cols = rows.map(&:length).max
26
+ return nil if max_cols.nil? || max_cols <= 1
27
+
28
+ col_widths = Array.new(max_cols, 0)
29
+ rows.each do |fields|
30
+ fields.each_with_index do |field, i|
31
+ w = RuVim::DisplayWidth.display_width(field)
32
+ col_widths[i] = w if w > col_widths[i]
33
+ end
34
+ end
35
+ col_widths
36
+ end
37
+
38
+ # Format a single raw line using pre-computed column widths.
39
+ def format_line(raw_line, delimiter:, col_widths:)
40
+ fields = split_fields(raw_line, delimiter)
41
+ padded = fields.each_with_index.map do |field, i|
42
+ pad_field(field, col_widths[i] || 0)
43
+ end
44
+ (fields.length...col_widths.length).each do |i|
45
+ padded << " " * col_widths[i]
46
+ end
47
+ padded.join(SEPARATOR)
48
+ end
49
+
50
+ # Map a character index in the raw line to the corresponding index
51
+ # in the formatted line. Needed for correct horizontal scrolling
52
+ # and cursor placement in rich mode.
53
+ def raw_to_formatted_char_index(raw_line, raw_char_index, delimiter:, col_widths:)
54
+ fields = split_fields(raw_line, delimiter)
55
+ pos = 0
56
+ formatted_pos = 0
57
+ fields.each_with_index do |field, i|
58
+ field_end = pos + field.length
59
+ if raw_char_index <= field_end
60
+ offset = [raw_char_index - pos, field.length].min
61
+ return formatted_pos + offset
62
+ end
63
+ pos = field_end + 1 # skip delimiter
64
+ field_dw = RuVim::DisplayWidth.display_width(field)
65
+ pad_chars = [(col_widths[i] || 0) - field_dw, 0].max
66
+ formatted_pos += field.length + pad_chars + SEPARATOR.length
67
+ end
68
+ formatted_pos
69
+ end
70
+
71
+ # Map a character index in the raw line to the display column
72
+ # in the formatted line. Unlike raw_to_formatted_char_index (which
73
+ # returns a character index), this returns the screen column directly,
74
+ # which is needed for display-column-based horizontal scrolling.
75
+ def raw_to_formatted_display_col(raw_line, raw_char_index, delimiter:, col_widths:)
76
+ fields = split_fields(raw_line, delimiter)
77
+ pos = 0
78
+ display_col = 0
79
+ fields.each_with_index do |field, i|
80
+ field_end = pos + field.length
81
+ if raw_char_index <= field_end
82
+ offset_chars = [raw_char_index - pos, field.length].min
83
+ offset_dw = RuVim::DisplayWidth.display_width(field[0...offset_chars])
84
+ return display_col + offset_dw
85
+ end
86
+ display_col += (col_widths[i] || 0) + SEPARATOR_WIDTH
87
+ pos = field_end + 1
88
+ end
89
+ display_col
90
+ end
91
+
92
+ # Compute the display column for the cursor position in a formatted line.
93
+ def cursor_display_col(raw_line, cursor_x, visible_lines:, delimiter:)
94
+ col_widths = compute_col_widths(visible_lines, delimiter: delimiter)
95
+ return RuVim::TextMetrics.screen_col_for_char_index(raw_line, cursor_x) unless col_widths
96
+
97
+ raw_to_formatted_display_col(raw_line, cursor_x, delimiter: delimiter, col_widths: col_widths)
98
+ end
99
+
100
+ # Render visible lines: split by delimiter, compute column widths, pad and join.
101
+ # Returns an array of formatted strings.
102
+ def render_visible(lines, delimiter:)
103
+ return lines if lines.nil? || lines.empty?
104
+
105
+ col_widths = compute_col_widths(lines, delimiter:)
106
+ return lines unless col_widths
107
+
108
+ lines.map { |line| format_line(line, delimiter:, col_widths:) }
109
+ end
110
+
111
+ # Split a line into fields respecting the delimiter.
112
+ # For CSV, handles quoted fields minimally.
113
+ def split_fields(line, delimiter)
114
+ if delimiter == ","
115
+ parse_csv_fields(line)
116
+ else
117
+ line.to_s.split(delimiter, -1)
118
+ end
119
+ end
120
+
121
+ # Minimal CSV field parser: handles double-quoted fields with embedded
122
+ # commas and escaped quotes ("").
123
+ def parse_csv_fields(line)
124
+ fields = []
125
+ s = line.to_s
126
+ pos = 0
127
+
128
+ while pos <= s.length
129
+ if pos < s.length && s[pos] == '"'
130
+ # Quoted field
131
+ pos += 1
132
+ field = +""
133
+ while pos < s.length
134
+ if s[pos] == '"'
135
+ if pos + 1 < s.length && s[pos + 1] == '"'
136
+ field << '"'
137
+ pos += 2
138
+ else
139
+ pos += 1
140
+ break
141
+ end
142
+ else
143
+ field << s[pos]
144
+ pos += 1
145
+ end
146
+ end
147
+ fields << field
148
+ # Skip comma after quoted field
149
+ pos += 1 if pos < s.length && s[pos] == ','
150
+ else
151
+ # Unquoted field
152
+ comma_idx = s.index(',', pos)
153
+ if comma_idx
154
+ fields << s[pos...comma_idx]
155
+ pos = comma_idx + 1
156
+ # Trailing comma means empty last field
157
+ fields << "" if pos == s.length
158
+ else
159
+ fields << s[pos..]
160
+ break
161
+ end
162
+ end
163
+ end
164
+
165
+ fields
166
+ end
167
+
168
+ # Pad a field to a target display width using spaces.
169
+ def pad_field(field, target_width)
170
+ current = RuVim::DisplayWidth.display_width(field)
171
+ gap = target_width - current
172
+ gap > 0 ? "#{field}#{' ' * gap}" : field
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rich_view/table_renderer"
4
+ require_relative "rich_view/markdown_renderer"
5
+ require_relative "rich_view/json_renderer"
6
+ require_relative "rich_view/jsonl_renderer"
7
+
8
+ module RuVim
9
+ module RichView
10
+ @renderers = {}
11
+ @detectors = []
12
+
13
+ module_function
14
+
15
+ def register(filetype, renderer, detector: nil)
16
+ @renderers[filetype.to_s] = renderer
17
+ @detectors << { filetype: filetype.to_s, detector: detector } if detector
18
+ end
19
+
20
+ def renderer_for(filetype)
21
+ @renderers[filetype.to_s]
22
+ end
23
+
24
+ def registered_filetypes
25
+ @renderers.keys
26
+ end
27
+
28
+ # Detect format from filetype or buffer content.
29
+ # Returns a filetype string ("tsv", "csv", "markdown") or nil.
30
+ def detect_format(buffer)
31
+ ft = buffer.options["filetype"].to_s
32
+ return ft if @renderers.key?(ft)
33
+
34
+ # Ask registered detectors
35
+ @detectors.each do |entry|
36
+ return entry[:filetype] if entry[:detector].call(buffer)
37
+ end
38
+
39
+ nil
40
+ end
41
+
42
+ # Enter rich mode on the current buffer (same buffer, no virtual buffer).
43
+ def open!(editor, format: nil)
44
+ buffer = editor.current_buffer
45
+ format ||= detect_format(buffer)
46
+ raise RuVim::CommandError, "Cannot detect format for rich view" unless format
47
+
48
+ renderer = @renderers[format]
49
+ raise RuVim::CommandError, "No renderer for format: #{format}" unless renderer
50
+
51
+ if renderer.respond_to?(:open_view!)
52
+ renderer.open_view!(editor)
53
+ return
54
+ end
55
+
56
+ delimiter = renderer.delimiter_for(format)
57
+ editor.enter_rich_mode(format: format, delimiter: delimiter)
58
+ editor.echo("[Rich: #{format}]")
59
+ end
60
+
61
+ # Check if rich view rendering is active (persists during command-line mode).
62
+ def active?(editor)
63
+ !!editor.rich_state
64
+ end
65
+
66
+ # Render visible lines through the appropriate renderer.
67
+ # Takes raw lines from buffer, returns formatted lines for display.
68
+ def render_visible_lines(editor, lines, context: {})
69
+ state = editor.rich_state
70
+ return lines unless state
71
+
72
+ format = state[:format]
73
+ renderer = @renderers[format]
74
+ return lines unless renderer
75
+
76
+ delimiter = state[:delimiter]
77
+ if renderer.respond_to?(:needs_pre_context?) && renderer.needs_pre_context?
78
+ renderer.render_visible(lines, delimiter: delimiter, context: context)
79
+ else
80
+ renderer.render_visible(lines, delimiter: delimiter)
81
+ end
82
+ end
83
+
84
+ # Toggle: if in rich mode, exit; otherwise enter.
85
+ def toggle!(editor, format: nil)
86
+ if active?(editor)
87
+ close!(editor)
88
+ else
89
+ open!(editor, format: format)
90
+ end
91
+ end
92
+
93
+ # Exit rich mode and return to normal mode.
94
+ def close!(editor)
95
+ return unless active?(editor)
96
+
97
+ editor.exit_rich_mode
98
+ end
99
+
100
+ # Bind Esc and C-c to close a virtual buffer created by a renderer.
101
+ def bind_close_keys(editor, buffer_id)
102
+ km = editor.keymap_manager
103
+ return unless km
104
+
105
+ km.bind_buffer(buffer_id, "\e", "rich.close_buffer")
106
+ km.bind_buffer(buffer_id, "<C-c>", "rich.close_buffer")
107
+ end
108
+ end
109
+ end