ruvim 0.3.0 → 0.6.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +68 -7
  3. data/README.md +30 -7
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +18 -1
  10. data/docs/command.md +156 -10
  11. data/docs/config.md +10 -2
  12. data/docs/done.md +23 -0
  13. data/docs/spec.md +162 -25
  14. data/docs/todo.md +9 -0
  15. data/docs/tutorial.md +33 -1
  16. data/docs/vim_diff.md +31 -8
  17. data/ext/ruvim/extconf.rb +5 -0
  18. data/ext/ruvim/ruvim_ext.c +519 -0
  19. data/lib/ruvim/app.rb +246 -2525
  20. data/lib/ruvim/browser.rb +104 -0
  21. data/lib/ruvim/buffer.rb +43 -20
  22. data/lib/ruvim/cli.rb +6 -0
  23. data/lib/ruvim/command_invocation.rb +2 -2
  24. data/lib/ruvim/completion_manager.rb +708 -0
  25. data/lib/ruvim/dispatcher.rb +14 -8
  26. data/lib/ruvim/display_width.rb +91 -45
  27. data/lib/ruvim/editor.rb +74 -80
  28. data/lib/ruvim/ex_command_registry.rb +3 -1
  29. data/lib/ruvim/file_watcher.rb +243 -0
  30. data/lib/ruvim/gh/link.rb +207 -0
  31. data/lib/ruvim/git/blame.rb +255 -0
  32. data/lib/ruvim/git/branch.rb +112 -0
  33. data/lib/ruvim/git/commit.rb +102 -0
  34. data/lib/ruvim/git/diff.rb +129 -0
  35. data/lib/ruvim/git/grep.rb +107 -0
  36. data/lib/ruvim/git/handler.rb +125 -0
  37. data/lib/ruvim/git/log.rb +41 -0
  38. data/lib/ruvim/git/status.rb +103 -0
  39. data/lib/ruvim/global_commands.rb +351 -77
  40. data/lib/ruvim/highlighter.rb +4 -11
  41. data/lib/ruvim/input.rb +1 -0
  42. data/lib/ruvim/key_handler.rb +1510 -0
  43. data/lib/ruvim/keymap_manager.rb +7 -7
  44. data/lib/ruvim/lang/base.rb +5 -0
  45. data/lib/ruvim/lang/c.rb +116 -0
  46. data/lib/ruvim/lang/cpp.rb +107 -0
  47. data/lib/ruvim/lang/csv.rb +4 -1
  48. data/lib/ruvim/lang/diff.rb +43 -0
  49. data/lib/ruvim/lang/dockerfile.rb +36 -0
  50. data/lib/ruvim/lang/elixir.rb +85 -0
  51. data/lib/ruvim/lang/erb.rb +30 -0
  52. data/lib/ruvim/lang/go.rb +83 -0
  53. data/lib/ruvim/lang/html.rb +34 -0
  54. data/lib/ruvim/lang/javascript.rb +83 -0
  55. data/lib/ruvim/lang/json.rb +40 -0
  56. data/lib/ruvim/lang/lua.rb +76 -0
  57. data/lib/ruvim/lang/makefile.rb +36 -0
  58. data/lib/ruvim/lang/markdown.rb +3 -4
  59. data/lib/ruvim/lang/ocaml.rb +77 -0
  60. data/lib/ruvim/lang/perl.rb +91 -0
  61. data/lib/ruvim/lang/python.rb +85 -0
  62. data/lib/ruvim/lang/registry.rb +102 -0
  63. data/lib/ruvim/lang/ruby.rb +7 -0
  64. data/lib/ruvim/lang/rust.rb +95 -0
  65. data/lib/ruvim/lang/scheme.rb +5 -0
  66. data/lib/ruvim/lang/sh.rb +76 -0
  67. data/lib/ruvim/lang/sql.rb +52 -0
  68. data/lib/ruvim/lang/toml.rb +36 -0
  69. data/lib/ruvim/lang/tsv.rb +4 -1
  70. data/lib/ruvim/lang/typescript.rb +53 -0
  71. data/lib/ruvim/lang/yaml.rb +62 -0
  72. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  73. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  74. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  75. data/lib/ruvim/rich_view.rb +30 -7
  76. data/lib/ruvim/screen.rb +135 -84
  77. data/lib/ruvim/stream/file_load.rb +85 -0
  78. data/lib/ruvim/stream/follow.rb +40 -0
  79. data/lib/ruvim/stream/git.rb +43 -0
  80. data/lib/ruvim/stream/run.rb +74 -0
  81. data/lib/ruvim/stream/stdin.rb +55 -0
  82. data/lib/ruvim/stream.rb +35 -0
  83. data/lib/ruvim/stream_mixer.rb +394 -0
  84. data/lib/ruvim/terminal.rb +18 -4
  85. data/lib/ruvim/text_metrics.rb +84 -65
  86. data/lib/ruvim/version.rb +1 -1
  87. data/lib/ruvim/window.rb +5 -5
  88. data/lib/ruvim.rb +31 -4
  89. data/test/app_command_test.rb +382 -0
  90. data/test/app_completion_test.rb +65 -16
  91. data/test/app_dot_repeat_test.rb +27 -3
  92. data/test/app_ex_command_test.rb +154 -0
  93. data/test/app_motion_test.rb +13 -12
  94. data/test/app_register_test.rb +2 -1
  95. data/test/app_scenario_test.rb +182 -8
  96. data/test/app_startup_test.rb +70 -27
  97. data/test/app_text_object_test.rb +2 -1
  98. data/test/app_unicode_behavior_test.rb +3 -2
  99. data/test/browser_test.rb +88 -0
  100. data/test/buffer_test.rb +24 -0
  101. data/test/cli_test.rb +77 -0
  102. data/test/clipboard_test.rb +67 -0
  103. data/test/command_invocation_test.rb +33 -0
  104. data/test/command_line_test.rb +118 -0
  105. data/test/config_dsl_test.rb +134 -0
  106. data/test/dispatcher_test.rb +74 -4
  107. data/test/display_width_test.rb +41 -0
  108. data/test/ex_command_registry_test.rb +106 -0
  109. data/test/file_watcher_test.rb +197 -0
  110. data/test/follow_test.rb +198 -0
  111. data/test/gh_link_test.rb +141 -0
  112. data/test/git_blame_test.rb +792 -0
  113. data/test/git_grep_test.rb +64 -0
  114. data/test/highlighter_test.rb +169 -0
  115. data/test/indent_test.rb +223 -0
  116. data/test/input_screen_integration_test.rb +1 -1
  117. data/test/keyword_chars_test.rb +85 -0
  118. data/test/lang_test.rb +634 -0
  119. data/test/markdown_renderer_test.rb +5 -5
  120. data/test/on_save_hook_test.rb +12 -8
  121. data/test/render_snapshot_test.rb +78 -0
  122. data/test/rich_view_test.rb +279 -23
  123. data/test/run_command_test.rb +307 -0
  124. data/test/screen_test.rb +68 -5
  125. data/test/search_option_test.rb +19 -0
  126. data/test/stream_test.rb +165 -0
  127. data/test/test_helper.rb +9 -0
  128. data/test/window_test.rb +59 -0
  129. metadata +68 -2
@@ -66,8 +66,9 @@ class OnSaveHookTest < Minitest::Test
66
66
  editor.current_buffer.replace_all_lines!(["hello"])
67
67
 
68
68
  # Execute :w command
69
+ kh = app.instance_variable_get(:@key_handler)
69
70
  keys = ":w #{path}\n".chars
70
- keys.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
71
+ keys.each { |k| kh.handle(k == "\n" ? :enter : k) }
71
72
 
72
73
  assert called, "on_save hook should have been called"
73
74
  end
@@ -84,15 +85,16 @@ class OnSaveHookTest < Minitest::Test
84
85
  File.write(path, "x = 1\ndef foo(\n")
85
86
 
86
87
  # Open and write the file
87
- ":e #{path}\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
88
- ":w\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
88
+ kh = app.instance_variable_get(:@key_handler)
89
+ ":e #{path}\n".chars.each { |k| kh.handle(k == "\n" ? :enter : k) }
90
+ ":w\n".chars.each { |k| kh.handle(k == "\n" ? :enter : k) }
89
91
 
90
92
  refute_empty editor.quickfix_items, "quickfix should be populated after :w with syntax error"
91
93
  assert_nil editor.quickfix_index, "quickfix index should be nil before navigation"
92
94
 
93
95
  # Press ]q — should jump to first item (index 0)
94
- app.send(:handle_key, "]")
95
- app.send(:handle_key, "q")
96
+ kh.handle("]")
97
+ kh.handle("q")
96
98
 
97
99
  assert_equal 0, editor.quickfix_index
98
100
  first_item = editor.quickfix_items.first
@@ -112,12 +114,13 @@ class OnSaveHookTest < Minitest::Test
112
114
  path = File.join(dir, "ok.rb")
113
115
  File.write(path, "puts 'hi'\n")
114
116
 
115
- ":e #{path}\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
117
+ kh = app.instance_variable_get(:@key_handler)
118
+ ":e #{path}\n".chars.each { |k| kh.handle(k == "\n" ? :enter : k) }
116
119
  # Set some dummy quickfix items first
117
120
  editor.set_quickfix_list([{ buffer_id: editor.current_buffer.id, row: 0, col: 0, text: "dummy" }])
118
121
  refute_empty editor.quickfix_items
119
122
 
120
- ":w\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
123
+ ":w\n".chars.each { |k| kh.handle(k == "\n" ? :enter : k) }
121
124
  assert_empty editor.quickfix_items, "quickfix should be cleared after :w with valid file"
122
125
  end
123
126
  end
@@ -141,8 +144,9 @@ class OnSaveHookTest < Minitest::Test
141
144
  editor.current_buffer.replace_all_lines!(["hello"])
142
145
  editor.set_option("onsavehook", false)
143
146
 
147
+ kh = app.instance_variable_get(:@key_handler)
144
148
  keys = ":w #{path}\n".chars
145
- keys.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
149
+ keys.each { |k| kh.handle(k == "\n" ? :enter : k) }
146
150
 
147
151
  refute called, "on_save hook should NOT have been called when onsavehook is disabled"
148
152
  end
@@ -64,7 +64,85 @@ class RenderSnapshotTest < Minitest::Test
64
64
  (1..rows).map { |row| strip_ansi(frame[:lines][row].to_s) }.join("\n")
65
65
  end
66
66
 
67
+ def build_raw_frame(lines:, winsize:, number: false, rich_format: nil, cursor_y: 0, cursor_x: 0)
68
+ editor = RuVim::Editor.new
69
+ buf = editor.add_empty_buffer
70
+ win = editor.add_window(buffer_id: buf.id)
71
+ buf.replace_all_lines!(lines)
72
+ editor.set_option("number", number, scope: :window, window: win, buffer: buf)
73
+ win.cursor_y = cursor_y
74
+ win.cursor_x = cursor_x
75
+ if rich_format
76
+ editor.instance_variable_set(:@rich_state, { format: rich_format })
77
+ end
78
+
79
+ term = TerminalStub.new(winsize)
80
+ screen = RuVim::Screen.new(terminal: term)
81
+
82
+ rows, cols = term.winsize
83
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
84
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
85
+ win.ensure_visible(buf, height: text_rows, width: text_cols, tabstop: 2)
86
+ frame = screen.send(:build_frame, editor, rows:, cols:, text_rows:, text_cols:, rects:)
87
+
88
+ (1..rows).map { |row| frame[:lines][row].to_s }.join("\n")
89
+ end
90
+
67
91
  def strip_ansi(str)
68
92
  str.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
69
93
  end
70
94
  end
95
+
96
+ class RenderSanitizeTest < Minitest::Test
97
+ TerminalStub = Struct.new(:winsize) do
98
+ def write(_data); end
99
+ end
100
+
101
+ def test_normal_buffer_sanitizes_escape_sequences
102
+ # ESC (0x1B) should be replaced with "?" in normal rendering
103
+ lines = ["hello\x1b]52;c;dGVzdA==\x07world"]
104
+ snapshot = build_raw_frame(lines: lines, winsize: [5, 40])
105
+ refute_includes snapshot, "\x1b]52"
106
+ refute_includes snapshot, "\x07"
107
+ end
108
+
109
+ def test_rich_view_sanitizes_escape_sequences_in_tsv
110
+ lines = ["col1\tcol2", "val1\t\x1b]52;c;dGVzdA==\x07evil"]
111
+ snapshot = build_raw_frame(lines: lines, winsize: [6, 40], rich_format: :tsv)
112
+ refute_includes snapshot, "\x1b]52"
113
+ refute_includes snapshot, "\x07"
114
+ end
115
+
116
+ def test_rich_view_sanitizes_escape_sequences_in_markdown
117
+ lines = ["# heading", "\x1b]52;c;dGVzdA==\x07evil text"]
118
+ snapshot = build_raw_frame(lines: lines, winsize: [6, 40], rich_format: :markdown)
119
+ refute_includes snapshot, "\x1b]52"
120
+ refute_includes snapshot, "\x07"
121
+ end
122
+
123
+ private
124
+
125
+ def build_raw_frame(lines:, winsize:, number: false, rich_format: nil, cursor_y: 0, cursor_x: 0)
126
+ editor = RuVim::Editor.new
127
+ buf = editor.add_empty_buffer
128
+ win = editor.add_window(buffer_id: buf.id)
129
+ buf.replace_all_lines!(lines)
130
+ editor.set_option("number", number, scope: :window, window: win, buffer: buf)
131
+ win.cursor_y = cursor_y
132
+ win.cursor_x = cursor_x
133
+ if rich_format
134
+ editor.instance_variable_set(:@rich_state, { format: rich_format })
135
+ end
136
+
137
+ term = TerminalStub.new(winsize)
138
+ screen = RuVim::Screen.new(terminal: term)
139
+
140
+ rows, cols = term.winsize
141
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
142
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
143
+ win.ensure_visible(buf, height: text_rows, width: text_cols, tabstop: 2)
144
+ frame = screen.send(:build_frame, editor, rows:, cols:, text_rows:, text_cols:, rects:)
145
+
146
+ (1..rows).map { |row| frame[:lines][row].to_s }.join("\n")
147
+ end
148
+ end
@@ -4,36 +4,36 @@ class RichViewTest < Minitest::Test
4
4
  # --- Framework tests ---
5
5
 
6
6
  def test_register_and_renderer_for
7
- assert RuVim::RichView.renderer_for("tsv")
8
- assert RuVim::RichView.renderer_for("csv")
9
- assert_nil RuVim::RichView.renderer_for("unknown")
7
+ assert RuVim::RichView.renderer_for(:tsv)
8
+ assert RuVim::RichView.renderer_for(:csv)
9
+ assert_nil RuVim::RichView.renderer_for(:unknown)
10
10
  end
11
11
 
12
12
  def test_registered_filetypes
13
13
  fts = RuVim::RichView.registered_filetypes
14
- assert_includes fts, "tsv"
15
- assert_includes fts, "csv"
14
+ assert_includes fts, :tsv
15
+ assert_includes fts, :csv
16
16
  end
17
17
 
18
18
  def test_detect_format_from_filetype
19
19
  editor = fresh_editor
20
20
  buf = editor.current_buffer
21
21
  buf.options["filetype"] = "csv"
22
- assert_equal "csv", RuVim::RichView.detect_format(buf)
22
+ assert_equal :csv, RuVim::RichView.detect_format(buf)
23
23
  end
24
24
 
25
25
  def test_detect_format_auto_tsv
26
26
  editor = fresh_editor
27
27
  buf = editor.current_buffer
28
28
  buf.replace_all_lines!(["a\tb\tc", "d\te\tf"])
29
- assert_equal "tsv", RuVim::RichView.detect_format(buf)
29
+ assert_equal :tsv, RuVim::RichView.detect_format(buf)
30
30
  end
31
31
 
32
32
  def test_detect_format_auto_csv
33
33
  editor = fresh_editor
34
34
  buf = editor.current_buffer
35
35
  buf.replace_all_lines!(["a,b,c", "d,e,f"])
36
- assert_equal "csv", RuVim::RichView.detect_format(buf)
36
+ assert_equal :csv, RuVim::RichView.detect_format(buf)
37
37
  end
38
38
 
39
39
  def test_detect_format_returns_nil_for_plain_text
@@ -63,7 +63,7 @@ class RichViewTest < Minitest::Test
63
63
  buf.replace_all_lines!(["a\tb", "c\td"])
64
64
  buf.options["filetype"] = "tsv"
65
65
 
66
- RuVim::RichView.open!(editor, format: "tsv")
66
+ RuVim::RichView.open!(editor, format: :tsv)
67
67
  assert_equal :rich, editor.mode
68
68
  assert RuVim::RichView.active?(editor)
69
69
  end
@@ -75,7 +75,7 @@ class RichViewTest < Minitest::Test
75
75
  buf.options["filetype"] = "tsv"
76
76
  original_id = buf.id
77
77
 
78
- RuVim::RichView.open!(editor, format: "tsv")
78
+ RuVim::RichView.open!(editor, format: :tsv)
79
79
  assert_equal original_id, editor.current_buffer.id
80
80
  end
81
81
 
@@ -85,7 +85,7 @@ class RichViewTest < Minitest::Test
85
85
  buf.replace_all_lines!(["x\ty"])
86
86
  buf.options["filetype"] = "tsv"
87
87
 
88
- RuVim::RichView.open!(editor, format: "tsv")
88
+ RuVim::RichView.open!(editor, format: :tsv)
89
89
  assert_equal :rich, editor.mode
90
90
 
91
91
  RuVim::RichView.close!(editor)
@@ -100,7 +100,7 @@ class RichViewTest < Minitest::Test
100
100
  buf.options["filetype"] = "tsv"
101
101
  original_id = buf.id
102
102
 
103
- RuVim::RichView.open!(editor, format: "tsv")
103
+ RuVim::RichView.open!(editor, format: :tsv)
104
104
  RuVim::RichView.close!(editor)
105
105
  assert_equal original_id, editor.current_buffer.id
106
106
  end
@@ -124,9 +124,9 @@ class RichViewTest < Minitest::Test
124
124
  buf.replace_all_lines!(["a\tb"])
125
125
  buf.options["filetype"] = "tsv"
126
126
 
127
- RuVim::RichView.open!(editor, format: "tsv")
127
+ RuVim::RichView.open!(editor, format: :tsv)
128
128
  state = editor.rich_state
129
- assert_equal "tsv", state[:format]
129
+ assert_equal :tsv, state[:format]
130
130
  assert_equal "\t", state[:delimiter]
131
131
  end
132
132
 
@@ -136,9 +136,9 @@ class RichViewTest < Minitest::Test
136
136
  buf.replace_all_lines!(["a,b"])
137
137
  buf.options["filetype"] = "csv"
138
138
 
139
- RuVim::RichView.open!(editor, format: "csv")
139
+ RuVim::RichView.open!(editor, format: :csv)
140
140
  state = editor.rich_state
141
- assert_equal "csv", state[:format]
141
+ assert_equal :csv, state[:format]
142
142
  assert_equal ",", state[:delimiter]
143
143
  end
144
144
 
@@ -148,7 +148,7 @@ class RichViewTest < Minitest::Test
148
148
  buf.replace_all_lines!(["a\tb"])
149
149
  buf.options["filetype"] = "tsv"
150
150
 
151
- RuVim::RichView.open!(editor, format: "tsv")
151
+ RuVim::RichView.open!(editor, format: :tsv)
152
152
  RuVim::RichView.close!(editor)
153
153
  assert_nil editor.rich_state
154
154
  end
@@ -159,7 +159,7 @@ class RichViewTest < Minitest::Test
159
159
  buf.replace_all_lines!(["a\tb"])
160
160
  buf.options["filetype"] = "tsv"
161
161
 
162
- RuVim::RichView.open!(editor, format: "tsv")
162
+ RuVim::RichView.open!(editor, format: :tsv)
163
163
  editor.enter_command_line_mode(":")
164
164
  assert_equal :command_line, editor.mode
165
165
  assert RuVim::RichView.active?(editor)
@@ -171,7 +171,7 @@ class RichViewTest < Minitest::Test
171
171
  buf.replace_all_lines!(["a\tb"])
172
172
  buf.options["filetype"] = "tsv"
173
173
 
174
- RuVim::RichView.open!(editor, format: "tsv")
174
+ RuVim::RichView.open!(editor, format: :tsv)
175
175
  assert_equal :rich, editor.mode
176
176
 
177
177
  editor.enter_command_line_mode(":")
@@ -189,7 +189,7 @@ class RichViewTest < Minitest::Test
189
189
  buf.replace_all_lines!(["a\tb"])
190
190
  buf.options["filetype"] = "tsv"
191
191
 
192
- RuVim::RichView.open!(editor, format: "tsv")
192
+ RuVim::RichView.open!(editor, format: :tsv)
193
193
  editor.enter_command_line_mode(":")
194
194
  editor.leave_command_line
195
195
  assert_equal :rich, editor.mode
@@ -210,7 +210,7 @@ class RichViewTest < Minitest::Test
210
210
  buf.replace_all_lines!(["a\tb"])
211
211
  buf.options["filetype"] = "tsv"
212
212
 
213
- RuVim::RichView.open!(editor, format: "tsv")
213
+ RuVim::RichView.open!(editor, format: :tsv)
214
214
  editor.enter_normal_mode
215
215
  assert_nil editor.rich_state
216
216
  assert_equal :normal, editor.mode
@@ -222,7 +222,7 @@ class RichViewTest < Minitest::Test
222
222
  buf.replace_all_lines!(["a\tbb", "ccc\td"])
223
223
  buf.options["filetype"] = "tsv"
224
224
 
225
- RuVim::RichView.open!(editor, format: "tsv")
225
+ RuVim::RichView.open!(editor, format: :tsv)
226
226
  lines = [buf.line_at(0), buf.line_at(1)]
227
227
  rendered = RuVim::RichView.render_visible_lines(editor, lines)
228
228
  assert_equal 2, rendered.length
@@ -236,7 +236,7 @@ class RichViewTest < Minitest::Test
236
236
  buf.options["filetype"] = "tsv"
237
237
  count_before = editor.buffers.length
238
238
 
239
- RuVim::RichView.open!(editor, format: "tsv")
239
+ RuVim::RichView.open!(editor, format: :tsv)
240
240
  assert_equal count_before, editor.buffers.length
241
241
  end
242
242
 
@@ -459,6 +459,161 @@ class RichViewTest < Minitest::Test
459
459
  assert_equal dc0, dc1, "Second field start should align across CJK and ASCII rows"
460
460
  end
461
461
 
462
+ # --- JSON Rich View tests ---
463
+
464
+ def test_json_registered
465
+ assert RuVim::RichView.renderer_for(:json)
466
+ end
467
+
468
+ def test_json_open_creates_virtual_buffer
469
+ editor = fresh_editor
470
+ buf = editor.current_buffer
471
+ buf.replace_all_lines!(['{"a":1,"b":[2,3]}'])
472
+ buf.options["filetype"] = "json"
473
+ count_before = editor.buffers.length
474
+
475
+ RuVim::RichView.open!(editor, format: :json)
476
+ assert_equal count_before + 1, editor.buffers.length
477
+ new_buf = editor.current_buffer
478
+ refute_equal buf.id, new_buf.id
479
+ assert_equal :json_formatted, new_buf.kind
480
+ assert new_buf.readonly?
481
+ end
482
+
483
+ def test_json_open_binds_close_keys
484
+ editor = fresh_editor
485
+ editor.keymap_manager = RuVim::KeymapManager.new
486
+ buf = editor.current_buffer
487
+ buf.replace_all_lines!(['{"a":1}'])
488
+ buf.options["filetype"] = "json"
489
+
490
+ RuVim::RichView.open!(editor, format: :json)
491
+ result = editor.keymap_manager.resolve_with_context(:normal, ["\e"], editor: editor)
492
+ assert_equal "rich.close_buffer", result.invocation.id
493
+ end
494
+
495
+ def test_json_open_pretty_prints
496
+ editor = fresh_editor
497
+ buf = editor.current_buffer
498
+ buf.replace_all_lines!(['{"a":1,"b":[2,3]}'])
499
+ buf.options["filetype"] = "json"
500
+
501
+ RuVim::RichView.open!(editor, format: :json)
502
+ new_buf = editor.current_buffer
503
+ lines = new_buf.lines
504
+ assert lines.length > 1, "Minified JSON should be expanded to multiple lines"
505
+ assert_equal "{", lines.first.strip
506
+ assert_equal "}", lines.last.strip
507
+ end
508
+
509
+ def test_json_open_multiline_buffer
510
+ editor = fresh_editor
511
+ buf = editor.current_buffer
512
+ buf.replace_all_lines!(['{', '"key": "value"', '}'])
513
+ buf.options["filetype"] = "json"
514
+
515
+ RuVim::RichView.open!(editor, format: :json)
516
+ new_buf = editor.current_buffer
517
+ lines = new_buf.lines
518
+ assert lines.length >= 3
519
+ end
520
+
521
+ def test_json_open_invalid_json_shows_error
522
+ editor = fresh_editor
523
+ buf = editor.current_buffer
524
+ buf.replace_all_lines!(['{"invalid json'])
525
+ buf.options["filetype"] = "json"
526
+
527
+ RuVim::RichView.open!(editor, format: :json)
528
+ # Should stay on original buffer
529
+ assert_equal buf.id, editor.current_buffer.id
530
+ assert_match(/JSON/, editor.message.to_s)
531
+ end
532
+
533
+ def test_json_open_does_not_enter_rich_mode
534
+ editor = fresh_editor
535
+ buf = editor.current_buffer
536
+ buf.replace_all_lines!(['{"a":1}'])
537
+ buf.options["filetype"] = "json"
538
+
539
+ RuVim::RichView.open!(editor, format: :json)
540
+ # Virtual buffer approach — no rich mode
541
+ assert_equal :normal, editor.mode
542
+ assert_nil editor.rich_state
543
+ end
544
+
545
+ def test_json_cursor_maps_to_formatted_line
546
+ editor = fresh_editor
547
+ buf = editor.current_buffer
548
+ # {"a":1,"b":{"c":2}}
549
+ buf.replace_all_lines!(['{"a":1,"b":{"c":2}}'])
550
+ buf.options["filetype"] = "json"
551
+
552
+ # Place cursor at "c" key — find its offset
553
+ line = buf.line_at(0)
554
+ idx = line.index('"c"')
555
+ editor.current_window.cursor_x = idx
556
+
557
+ RuVim::RichView.open!(editor, format: :json)
558
+ new_buf = editor.current_buffer
559
+ # Cursor should be on the line containing "c"
560
+ cursor_line = new_buf.line_at(editor.current_window.cursor_y)
561
+ assert_match(/"c"/, cursor_line, "Cursor should be on the line with \"c\" key")
562
+ end
563
+
564
+ def test_json_cursor_maps_multiline_source
565
+ editor = fresh_editor
566
+ buf = editor.current_buffer
567
+ buf.replace_all_lines!(['{', ' "x": [1, 2, 3]', '}'])
568
+ buf.options["filetype"] = "json"
569
+
570
+ # Place cursor on line 1 at the "x" key (col 2 = opening quote)
571
+ editor.current_window.cursor_y = 1
572
+ editor.current_window.cursor_x = 2
573
+
574
+ RuVim::RichView.open!(editor, format: :json)
575
+ new_buf = editor.current_buffer
576
+ cursor_line = new_buf.line_at(editor.current_window.cursor_y)
577
+ assert_match(/"x"/, cursor_line, "Cursor should be on the line with \"x\" key")
578
+ end
579
+
580
+ def test_json_cursor_at_start_stays_at_start
581
+ editor = fresh_editor
582
+ buf = editor.current_buffer
583
+ buf.replace_all_lines!(['{"a":1}'])
584
+ buf.options["filetype"] = "json"
585
+ editor.current_window.cursor_x = 0
586
+
587
+ RuVim::RichView.open!(editor, format: :json)
588
+ assert_equal 0, editor.current_window.cursor_y
589
+ end
590
+
591
+ def test_json_significant_offset
592
+ r = RuVim::RichView::JsonRenderer
593
+ # {"a" — 4 significant chars: { " a "
594
+ assert_equal 4, r.significant_char_count('{"a"', 4)
595
+ # { "a" — space outside string skipped, still 4 significant
596
+ assert_equal 4, r.significant_char_count('{ "a"', 5)
597
+ end
598
+
599
+ def test_json_line_for_significant_offset
600
+ formatted = "{\n \"a\": 1\n}"
601
+ r = RuVim::RichView::JsonRenderer
602
+ # count 0 → line 0 (before any char)
603
+ assert_equal 0, r.line_for_significant_count(formatted, 0)
604
+ # count 1 → line 0 ({ is the 1st significant char, on line 0)
605
+ assert_equal 0, r.line_for_significant_count(formatted, 1)
606
+ # count 2 → line 1 (" opening quote of "a" is on line 1)
607
+ assert_equal 1, r.line_for_significant_count(formatted, 2)
608
+ end
609
+
610
+ def test_json_filetype_detected
611
+ editor = fresh_editor
612
+ buf = editor.current_buffer
613
+ buf.options["filetype"] = "json"
614
+ assert_equal :json, RuVim::RichView.detect_format(buf)
615
+ end
616
+
462
617
  # --- Filetype detection tests ---
463
618
 
464
619
  def test_detect_filetype_tsv
@@ -475,4 +630,105 @@ class RichViewTest < Minitest::Test
475
630
  editor = RuVim::Editor.new
476
631
  assert_equal "tsv", editor.detect_filetype("DATA.TSV")
477
632
  end
633
+
634
+ def test_detect_filetype_jsonl
635
+ editor = RuVim::Editor.new
636
+ assert_equal "jsonl", editor.detect_filetype("data.jsonl")
637
+ end
638
+
639
+ # --- JSONL Rich View tests ---
640
+
641
+ def test_jsonl_registered
642
+ assert RuVim::RichView.renderer_for(:jsonl)
643
+ end
644
+
645
+ def test_jsonl_open_creates_virtual_buffer
646
+ editor = fresh_editor
647
+ buf = editor.current_buffer
648
+ buf.replace_all_lines!(['{"a":1}', '{"b":2}'])
649
+ buf.options["filetype"] = "jsonl"
650
+ count_before = editor.buffers.length
651
+
652
+ RuVim::RichView.open!(editor, format: :jsonl)
653
+ assert_equal count_before + 1, editor.buffers.length
654
+ new_buf = editor.current_buffer
655
+ refute_equal buf.id, new_buf.id
656
+ assert_equal :jsonl_formatted, new_buf.kind
657
+ assert new_buf.readonly?
658
+ end
659
+
660
+ def test_jsonl_open_binds_close_keys
661
+ editor = fresh_editor
662
+ editor.keymap_manager = RuVim::KeymapManager.new
663
+ buf = editor.current_buffer
664
+ buf.replace_all_lines!(['{"a":1}', '{"b":2}'])
665
+ buf.options["filetype"] = "jsonl"
666
+
667
+ RuVim::RichView.open!(editor, format: :jsonl)
668
+ result = editor.keymap_manager.resolve_with_context(:normal, ["\e"], editor: editor)
669
+ assert_equal "rich.close_buffer", result.invocation.id
670
+ end
671
+
672
+ def test_jsonl_open_pretty_prints_each_line
673
+ editor = fresh_editor
674
+ buf = editor.current_buffer
675
+ buf.replace_all_lines!(['{"a":1,"b":[2,3]}', '{"c":4}'])
676
+ buf.options["filetype"] = "jsonl"
677
+
678
+ RuVim::RichView.open!(editor, format: :jsonl)
679
+ new_buf = editor.current_buffer
680
+ lines = new_buf.lines
681
+ # Each JSON object should be expanded; separated by "---"
682
+ assert lines.length > 2, "JSONL should be expanded to multiple lines"
683
+ assert lines.any? { |l| l.include?("---") }, "Entries should be separated"
684
+ end
685
+
686
+ def test_jsonl_open_maps_cursor_to_correct_entry
687
+ editor = fresh_editor
688
+ buf = editor.current_buffer
689
+ buf.replace_all_lines!(['{"a":1}', '{"b":2}', '{"c":3}'])
690
+ buf.options["filetype"] = "jsonl"
691
+ editor.current_window.cursor_y = 1 # on second entry
692
+
693
+ RuVim::RichView.open!(editor, format: :jsonl)
694
+ new_buf = editor.current_buffer
695
+ cy = editor.current_window.cursor_y
696
+ # Cursor should be within the second entry's formatted block
697
+ nearby = (cy..[cy + 2, new_buf.lines.length - 1].min).map { |r| new_buf.line_at(r) }.join("\n")
698
+ assert_match(/"b"/, nearby, "Cursor should be near the entry with \"b\"")
699
+ end
700
+
701
+ def test_jsonl_open_skips_blank_lines
702
+ editor = fresh_editor
703
+ buf = editor.current_buffer
704
+ buf.replace_all_lines!(['{"a":1}', '', '{"b":2}'])
705
+ buf.options["filetype"] = "jsonl"
706
+
707
+ RuVim::RichView.open!(editor, format: :jsonl)
708
+ new_buf = editor.current_buffer
709
+ lines = new_buf.lines
710
+ # Should contain both entries
711
+ assert lines.any? { |l| l.include?('"a"') }
712
+ assert lines.any? { |l| l.include?('"b"') }
713
+ end
714
+
715
+ def test_jsonl_open_shows_parse_error_inline
716
+ editor = fresh_editor
717
+ buf = editor.current_buffer
718
+ buf.replace_all_lines!(['{"a":1}', 'bad json', '{"b":2}'])
719
+ buf.options["filetype"] = "jsonl"
720
+
721
+ RuVim::RichView.open!(editor, format: :jsonl)
722
+ new_buf = editor.current_buffer
723
+ lines = new_buf.lines
724
+ # Invalid line should show an error marker
725
+ assert lines.any? { |l| l.include?("PARSE ERROR") }, "Invalid JSON line should show error"
726
+ end
727
+
728
+ def test_jsonl_filetype_detected
729
+ editor = fresh_editor
730
+ buf = editor.current_buffer
731
+ buf.options["filetype"] = "jsonl"
732
+ assert_equal :jsonl, RuVim::RichView.detect_format(buf)
733
+ end
478
734
  end