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
data/lib/ruvim/buffer.rb CHANGED
@@ -1,9 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Buffer
3
5
  attr_reader :id, :kind, :name
4
6
  attr_accessor :path
5
7
  attr_reader :options
6
8
  attr_writer :modified
9
+ attr_accessor :stream_state, :loading_state, :follow_backend
10
+
11
+ def stream_status
12
+ return nil unless @stream_state
13
+
14
+ if @kind == :stream
15
+ "stdin/#{@stream_state}"
16
+ elsif @follow_backend == :inotify
17
+ "follow/i"
18
+ else
19
+ "follow"
20
+ end
21
+ end
22
+ attr_accessor :lang_module
7
23
 
8
24
  def self.from_file(id:, path:)
9
25
  lines =
@@ -54,12 +70,15 @@ module RuVim
54
70
  @modified = false
55
71
  @readonly = !!readonly
56
72
  @modifiable = !!modifiable
73
+ @stream_state = nil
74
+ @loading_state = nil
57
75
  @undo_stack = []
58
76
  @redo_stack = []
59
77
  @change_group_depth = 0
60
78
  @group_before_snapshot = nil
61
79
  @group_changed = false
62
80
  @recording_suspended = false
81
+ @lang_module = Lang::Base
63
82
  @options = {}
64
83
  end
65
84
 
@@ -80,7 +99,7 @@ module RuVim
80
99
  end
81
100
 
82
101
  def modifiable?
83
- @modifiable
102
+ @modifiable && @loading_state != :live && @stream_state != :live
84
103
  end
85
104
 
86
105
  def modifiable=(value)
@@ -108,6 +127,8 @@ module RuVim
108
127
  @name = name
109
128
  @readonly = !!readonly
110
129
  @modifiable = !!modifiable
130
+ @stream_state = nil unless @kind == :stream
131
+ @loading_state = nil
111
132
  self
112
133
  end
113
134
 
@@ -117,6 +138,8 @@ module RuVim
117
138
  @path = nil
118
139
  @readonly = false
119
140
  @modifiable = true
141
+ @stream_state = nil
142
+ @loading_state = nil
120
143
  @lines = [""]
121
144
  @modified = false
122
145
  @undo_stack.clear
@@ -329,6 +352,29 @@ module RuVim
329
352
  @modified = true
330
353
  end
331
354
 
355
+ # Append externally-streamed text without touching undo history or modifiable state.
356
+ def append_stream_text!(text)
357
+ chunk = text.to_s
358
+ return [@lines.length - 1, @lines[-1].length] if chunk.empty?
359
+
360
+ parts = chunk.split("\n", -1)
361
+ head = parts.shift || ""
362
+ @lines[-1] = @lines[-1].to_s + head
363
+ @lines.concat(parts)
364
+ @lines = [""] if @lines.empty?
365
+ @modified = false
366
+ [@lines.length - 1, @lines[-1].length]
367
+ end
368
+
369
+ def finalize_async_file_load!(ended_with_newline:)
370
+ if ended_with_newline && @lines.length > 1 && @lines[-1] == ""
371
+ @lines.pop
372
+ end
373
+ @lines = [""] if @lines.empty?
374
+ @modified = false
375
+ self
376
+ end
377
+
332
378
  def write_to(path = nil)
333
379
  raise RuVim::CommandError, "Buffer is readonly" if readonly?
334
380
 
data/lib/ruvim/cli.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class CLI
3
5
  Options = Struct.new(
@@ -14,6 +16,7 @@ module RuVim
14
16
  :no_swap,
15
17
  :nomodifiable,
16
18
  :restricted_mode,
19
+ :follow,
17
20
  :verbose_level,
18
21
  :startup_time_path,
19
22
  :startup_open_layout,
@@ -38,18 +41,25 @@ module RuVim
38
41
  return 0
39
42
  end
40
43
 
41
- if opts.files.length > 1 && opts.startup_open_layout.nil?
42
- raise ParseError, "multiple files are not supported yet"
43
- end
44
44
 
45
45
  if opts.config_path && !File.file?(opts.config_path)
46
46
  raise ParseError, "config file not found: #{opts.config_path}"
47
47
  end
48
48
 
49
+ ui_stdin = stdin
50
+ stdin_stream_mode = false
51
+ if stdin.respond_to?(:tty?) && !stdin.tty?
52
+ ui_stdin = IO.console
53
+ raise ParseError, "no controlling terminal available for interactive UI" unless ui_stdin
54
+ stdin_stream_mode = opts.files.empty?
55
+ end
56
+
49
57
  app = RuVim::App.new(
50
58
  path: opts.files.first,
51
59
  paths: opts.files,
52
60
  stdin: stdin,
61
+ ui_stdin: ui_stdin,
62
+ stdin_stream_mode: stdin_stream_mode,
53
63
  stdout: stdout,
54
64
  pre_config_actions: opts.pre_config_actions,
55
65
  startup_actions: opts.startup_actions,
@@ -61,6 +71,7 @@ module RuVim
61
71
  quickfix_errorfile: opts.quickfix_errorfile,
62
72
  session_file: opts.session_file,
63
73
  nomodifiable: opts.nomodifiable,
74
+ follow: opts.follow,
64
75
  restricted: opts.restricted_mode,
65
76
  verbose_level: opts.verbose_level,
66
77
  verbose_io: stderr,
@@ -92,6 +103,7 @@ module RuVim
92
103
  no_swap: false,
93
104
  nomodifiable: false,
94
105
  restricted_mode: false,
106
+ follow: false,
95
107
  verbose_level: 0,
96
108
  startup_time_path: nil,
97
109
  startup_open_layout: nil,
@@ -150,6 +162,8 @@ module RuVim
150
162
  end
151
163
  when "-n"
152
164
  opts.no_swap = true
165
+ when "-f"
166
+ opts.follow = true
153
167
  when "-M"
154
168
  opts.nomodifiable = true
155
169
  when "-Z"
@@ -231,6 +245,7 @@ module RuVim
231
245
  -h, --help Show this help
232
246
  -v, --version Show version
233
247
  --clean Start without user config and ftplugin
248
+ -f Open file in follow mode (tail -f style)
234
249
  -R Open file readonly (disallow :w on current buffer)
235
250
  -d Diff mode requested (compat placeholder; not implemented yet)
236
251
  -q {errorfile} Quickfix startup placeholder (not implemented yet)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "open3"
2
4
 
3
5
  module RuVim
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class CommandInvocation
3
5
  attr_accessor :id, :argv, :kwargs, :count, :bang, :raw_keys
@@ -6,7 +8,7 @@ module RuVim
6
8
  @id = id
7
9
  @argv = argv || []
8
10
  @kwargs = kwargs || {}
9
- @count = count || 1
11
+ @count = count
10
12
  @bang = !!bang
11
13
  @raw_keys = raw_keys
12
14
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class CommandLine
3
5
  attr_reader :prefix, :text, :cursor
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class CommandRegistry
3
5
  CommandSpec = Struct.new(
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class ConfigDSL < BasicObject
3
5
  def initialize(command_registry:, ex_registry:, keymaps:, command_host:, editor: nil, filetype: nil)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class ConfigLoader
3
5
  SAFE_FILETYPE_RE = /\A[a-zA-Z0-9_+-]+\z/.freeze
data/lib/ruvim/context.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Context
3
5
  attr_reader :editor, :invocation
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "shellwords"
2
4
 
3
5
  module RuVim
@@ -29,26 +31,40 @@ module RuVim
29
31
  return
30
32
  end
31
33
 
32
- if (sub = parse_global_substitute(line))
33
- invocation = CommandInvocation.new(id: "__substitute__", kwargs: sub)
34
+ # Parse range prefix
35
+ range_result = parse_range(raw, editor)
36
+ rest = range_result ? range_result[:rest] : raw
37
+
38
+ # Try substitute on rest
39
+ if (sub = parse_substitute(rest))
40
+ kwargs = sub.merge(
41
+ range_start: range_result&.dig(:range_start),
42
+ range_end: range_result&.dig(:range_end)
43
+ )
44
+ invocation = CommandInvocation.new(id: "__substitute__", kwargs:)
34
45
  ctx = Context.new(editor:, invocation:)
35
- @command_host.ex_substitute(ctx, **sub)
46
+ @command_host.ex_substitute(ctx, **kwargs)
36
47
  editor.enter_normal_mode
37
48
  return
38
49
  end
39
50
 
40
- parsed = parse_ex(line)
51
+ parsed = parse_ex(rest)
41
52
  return if parsed.nil?
42
53
 
43
54
  spec = @ex_registry.fetch(parsed.name)
44
55
  validate_ex_args!(spec, parsed.argv, parsed.bang)
45
56
  invocation = CommandInvocation.new(id: spec.name, argv: parsed.argv, bang: parsed.bang)
46
57
  ctx = Context.new(editor:, invocation:)
47
- @command_host.call(spec.call, ctx, argv: parsed.argv, bang: parsed.bang, count: 1, kwargs: {})
58
+ range_kwargs = {}
59
+ if range_result
60
+ range_kwargs[:range_start] = range_result[:range_start]
61
+ range_kwargs[:range_end] = range_result[:range_end]
62
+ end
63
+ @command_host.call(spec.call, ctx, argv: parsed.argv, bang: parsed.bang, count: 1, kwargs: range_kwargs)
48
64
  rescue StandardError => e
49
65
  editor.echo_error("Error: #{e.message}")
50
66
  ensure
51
- editor.enter_normal_mode
67
+ editor.leave_command_line if editor.mode == :command_line
52
68
  end
53
69
 
54
70
  def parse_ex(line)
@@ -66,28 +82,142 @@ module RuVim
66
82
  raise RuVim::CommandError, "Parse error: #{e.message}"
67
83
  end
68
84
 
69
- def parse_global_substitute(line)
85
+ # Parse a substitute command: s/pat/repl/flags
86
+ # Returns {pattern:, replacement:, flags_str:} or nil
87
+ def parse_substitute(line)
70
88
  raw = line.to_s.strip
71
- return nil unless raw.start_with?("%s")
72
- return nil if raw.length < 4
89
+ return nil unless raw.match?(/\As[^a-zA-Z]/)
90
+ return nil if raw.length < 2
73
91
 
74
- delim = raw[2]
92
+ delim = raw[1]
75
93
  return nil if delim.nil? || delim =~ /\s/
76
- i = 3
94
+ i = 2
77
95
  pat, i = parse_delimited_segment(raw, i, delim)
78
96
  return nil unless pat
79
97
  rep, i = parse_delimited_segment(raw, i, delim)
80
98
  return nil unless rep
81
- flags = raw[i..].to_s
99
+ flags_str = raw[i..].to_s
82
100
  {
83
101
  pattern: pat,
84
102
  replacement: rep,
85
- global: flags.include?("g")
103
+ flags_str: flags_str
86
104
  }
87
105
  rescue StandardError
88
106
  nil
89
107
  end
90
108
 
109
+ # Parse an address at position pos in str.
110
+ # Returns [resolved_line_number, new_pos] or nil.
111
+ def parse_address(str, pos, editor)
112
+ return nil if pos >= str.length
113
+
114
+ ch = str[pos]
115
+ base = nil
116
+ new_pos = pos
117
+
118
+ case ch
119
+ when /\d/
120
+ # Numeric address
121
+ m = str[pos..].match(/\A(\d+)/)
122
+ return nil unless m
123
+ base = m[1].to_i - 1 # convert 1-based to 0-based
124
+ new_pos = pos + m[0].length
125
+ when "."
126
+ base = editor.current_window.cursor_y
127
+ new_pos = pos + 1
128
+ when "$"
129
+ base = editor.current_buffer.line_count - 1
130
+ new_pos = pos + 1
131
+ when "'"
132
+ # Mark address
133
+ mark_ch = str[pos + 1]
134
+ return nil unless mark_ch
135
+ if mark_ch == "<" || mark_ch == ">"
136
+ sel = editor.visual_selection
137
+ if sel
138
+ base = mark_ch == "<" ? sel[:start_row] : sel[:end_row]
139
+ else
140
+ return nil
141
+ end
142
+ else
143
+ loc = editor.mark_location(mark_ch)
144
+ return nil unless loc
145
+ base = loc[:row]
146
+ end
147
+ new_pos = pos + 2
148
+ when "+", "-"
149
+ # Relative offset with implicit current line
150
+ base = editor.current_window.cursor_y
151
+ # Don't advance new_pos — the offset parsing below will handle +/-
152
+ else
153
+ return nil
154
+ end
155
+
156
+ # Parse trailing +N / -N offsets
157
+ while new_pos < str.length
158
+ offset_ch = str[new_pos]
159
+ if offset_ch == "+"
160
+ m = str[new_pos + 1..].to_s.match(/\A(\d+)/)
161
+ if m
162
+ base += m[1].to_i
163
+ new_pos += 1 + m[0].length
164
+ else
165
+ base += 1
166
+ new_pos += 1
167
+ end
168
+ elsif offset_ch == "-"
169
+ m = str[new_pos + 1..].to_s.match(/\A(\d+)/)
170
+ if m
171
+ base -= m[1].to_i
172
+ new_pos += 1 + m[0].length
173
+ else
174
+ base -= 1
175
+ new_pos += 1
176
+ end
177
+ else
178
+ break
179
+ end
180
+ end
181
+
182
+ # Clamp to valid range
183
+ max_line = editor.current_buffer.line_count - 1
184
+ base = [[base, 0].max, max_line].min
185
+
186
+ [base, new_pos]
187
+ end
188
+
189
+ # Parse a range from the beginning of raw.
190
+ # Returns {range_start:, range_end:, rest:} or nil.
191
+ def parse_range(raw, editor)
192
+ str = raw.to_s
193
+ return nil if str.empty?
194
+
195
+ # % = whole file
196
+ if str[0] == "%"
197
+ max_line = editor.current_buffer.line_count - 1
198
+ rest = str[1..].to_s
199
+ return { range_start: 0, range_end: max_line, rest: rest }
200
+ end
201
+
202
+ # Try first address
203
+ addr1 = parse_address(str, 0, editor)
204
+ return nil unless addr1
205
+
206
+ line1, pos = addr1
207
+
208
+ if pos < str.length && str[pos] == ","
209
+ # addr,addr range
210
+ addr2 = parse_address(str, pos + 1, editor)
211
+ if addr2
212
+ line2, pos2 = addr2
213
+ return { range_start: line1, range_end: line2, rest: str[pos2..].to_s }
214
+ end
215
+ end
216
+
217
+ # Single address
218
+ { range_start: line1, range_end: line1, rest: str[pos..].to_s }
219
+ end
220
+
91
221
  private
92
222
 
93
223
  def parse_delimited_segment(str, idx, delim)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  module DisplayWidth
3
5
  module_function
@@ -11,6 +13,7 @@ module RuVim
11
13
  end
12
14
 
13
15
  code = ch.ord
16
+ return 1 if code <= 0xA0 && !code.zero?
14
17
  return cached_codepoint_width(code) if codepoint_cacheable?(code)
15
18
 
16
19
  uncached_codepoint_width(code)