ruvim 0.1.0 → 0.3.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +84 -0
  4. data/CLAUDE.md +1 -0
  5. data/docs/binding.md +29 -0
  6. data/docs/command.md +101 -0
  7. data/docs/config.md +203 -84
  8. data/docs/done.md +21 -0
  9. data/docs/lib_cleanup_report.md +79 -0
  10. data/docs/plugin.md +13 -15
  11. data/docs/spec.md +195 -33
  12. data/docs/todo.md +183 -10
  13. data/docs/tutorial.md +1 -1
  14. data/docs/vim_diff.md +94 -171
  15. data/lib/ruvim/app.rb +1543 -172
  16. data/lib/ruvim/buffer.rb +35 -1
  17. data/lib/ruvim/cli.rb +12 -3
  18. data/lib/ruvim/clipboard.rb +2 -0
  19. data/lib/ruvim/command_invocation.rb +3 -1
  20. data/lib/ruvim/command_line.rb +2 -0
  21. data/lib/ruvim/command_registry.rb +2 -0
  22. data/lib/ruvim/config_dsl.rb +2 -0
  23. data/lib/ruvim/config_loader.rb +21 -5
  24. data/lib/ruvim/context.rb +2 -7
  25. data/lib/ruvim/dispatcher.rb +153 -13
  26. data/lib/ruvim/display_width.rb +28 -2
  27. data/lib/ruvim/editor.rb +622 -69
  28. data/lib/ruvim/ex_command_registry.rb +2 -0
  29. data/lib/ruvim/global_commands.rb +1386 -114
  30. data/lib/ruvim/highlighter.rb +16 -21
  31. data/lib/ruvim/input.rb +52 -29
  32. data/lib/ruvim/keymap_manager.rb +83 -0
  33. data/lib/ruvim/keyword_chars.rb +48 -0
  34. data/lib/ruvim/lang/base.rb +25 -0
  35. data/lib/ruvim/lang/csv.rb +18 -0
  36. data/lib/ruvim/lang/json.rb +18 -0
  37. data/lib/ruvim/lang/markdown.rb +170 -0
  38. data/lib/ruvim/lang/ruby.rb +236 -0
  39. data/lib/ruvim/lang/scheme.rb +44 -0
  40. data/lib/ruvim/lang/tsv.rb +19 -0
  41. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  42. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  43. data/lib/ruvim/rich_view.rb +93 -0
  44. data/lib/ruvim/screen.rb +851 -119
  45. data/lib/ruvim/terminal.rb +18 -1
  46. data/lib/ruvim/text_metrics.rb +28 -0
  47. data/lib/ruvim/version.rb +2 -2
  48. data/lib/ruvim/window.rb +37 -10
  49. data/lib/ruvim.rb +15 -0
  50. data/test/app_completion_test.rb +174 -0
  51. data/test/app_dot_repeat_test.rb +13 -0
  52. data/test/app_motion_test.rb +110 -2
  53. data/test/app_scenario_test.rb +998 -0
  54. data/test/app_startup_test.rb +197 -0
  55. data/test/arglist_test.rb +113 -0
  56. data/test/buffer_test.rb +49 -30
  57. data/test/config_loader_test.rb +37 -0
  58. data/test/dispatcher_test.rb +438 -0
  59. data/test/display_width_test.rb +18 -0
  60. data/test/editor_register_test.rb +23 -0
  61. data/test/fixtures/render_basic_snapshot.txt +7 -8
  62. data/test/fixtures/render_basic_snapshot_nonumber.txt +1 -2
  63. data/test/fixtures/render_unicode_scrolled_snapshot.txt +6 -7
  64. data/test/highlighter_test.rb +121 -0
  65. data/test/indent_test.rb +201 -0
  66. data/test/input_screen_integration_test.rb +65 -14
  67. data/test/markdown_renderer_test.rb +279 -0
  68. data/test/on_save_hook_test.rb +150 -0
  69. data/test/rich_view_test.rb +478 -0
  70. data/test/screen_test.rb +470 -0
  71. data/test/window_test.rb +26 -0
  72. metadata +37 -2
data/lib/ruvim/buffer.rb CHANGED
@@ -1,9 +1,13 @@
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
10
+ attr_accessor :lang_module
7
11
 
8
12
  def self.from_file(id:, path:)
9
13
  lines =
@@ -54,12 +58,15 @@ module RuVim
54
58
  @modified = false
55
59
  @readonly = !!readonly
56
60
  @modifiable = !!modifiable
61
+ @stream_state = nil
62
+ @loading_state = nil
57
63
  @undo_stack = []
58
64
  @redo_stack = []
59
65
  @change_group_depth = 0
60
66
  @group_before_snapshot = nil
61
67
  @group_changed = false
62
68
  @recording_suspended = false
69
+ @lang_module = Lang::Base
63
70
  @options = {}
64
71
  end
65
72
 
@@ -80,7 +87,7 @@ module RuVim
80
87
  end
81
88
 
82
89
  def modifiable?
83
- @modifiable
90
+ @modifiable && @loading_state != :live
84
91
  end
85
92
 
86
93
  def modifiable=(value)
@@ -108,6 +115,8 @@ module RuVim
108
115
  @name = name
109
116
  @readonly = !!readonly
110
117
  @modifiable = !!modifiable
118
+ @stream_state = nil unless @kind == :stream
119
+ @loading_state = nil
111
120
  self
112
121
  end
113
122
 
@@ -117,6 +126,8 @@ module RuVim
117
126
  @path = nil
118
127
  @readonly = false
119
128
  @modifiable = true
129
+ @stream_state = nil
130
+ @loading_state = nil
120
131
  @lines = [""]
121
132
  @modified = false
122
133
  @undo_stack.clear
@@ -329,6 +340,29 @@ module RuVim
329
340
  @modified = true
330
341
  end
331
342
 
343
+ # Append externally-streamed text without touching undo history or modifiable state.
344
+ def append_stream_text!(text)
345
+ chunk = text.to_s
346
+ return [@lines.length - 1, @lines[-1].length] if chunk.empty?
347
+
348
+ parts = chunk.split("\n", -1)
349
+ head = parts.shift || ""
350
+ @lines[-1] = @lines[-1].to_s + head
351
+ @lines.concat(parts)
352
+ @lines = [""] if @lines.empty?
353
+ @modified = false
354
+ [@lines.length - 1, @lines[-1].length]
355
+ end
356
+
357
+ def finalize_async_file_load!(ended_with_newline:)
358
+ if ended_with_newline && @lines.length > 1 && @lines[-1] == ""
359
+ @lines.pop
360
+ end
361
+ @lines = [""] if @lines.empty?
362
+ @modified = false
363
+ self
364
+ end
365
+
332
366
  def write_to(path = nil)
333
367
  raise RuVim::CommandError, "Buffer is readonly" if readonly?
334
368
 
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(
@@ -38,18 +40,25 @@ module RuVim
38
40
  return 0
39
41
  end
40
42
 
41
- if opts.files.length > 1 && opts.startup_open_layout.nil?
42
- raise ParseError, "multiple files are not supported yet"
43
- end
44
43
 
45
44
  if opts.config_path && !File.file?(opts.config_path)
46
45
  raise ParseError, "config file not found: #{opts.config_path}"
47
46
  end
48
47
 
48
+ ui_stdin = stdin
49
+ stdin_stream_mode = false
50
+ if stdin.respond_to?(:tty?) && !stdin.tty?
51
+ ui_stdin = IO.console
52
+ raise ParseError, "no controlling terminal available for interactive UI" unless ui_stdin
53
+ stdin_stream_mode = opts.files.empty?
54
+ end
55
+
49
56
  app = RuVim::App.new(
50
57
  path: opts.files.first,
51
58
  paths: opts.files,
52
59
  stdin: stdin,
60
+ ui_stdin: ui_stdin,
61
+ stdin_stream_mode: stdin_stream_mode,
53
62
  stdout: stdout,
54
63
  pre_config_actions: opts.pre_config_actions,
55
64
  startup_actions: opts.startup_actions,
@@ -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,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class ConfigLoader
5
+ SAFE_FILETYPE_RE = /\A[a-zA-Z0-9_+-]+\z/.freeze
6
+
3
7
  def initialize(command_registry:, ex_registry:, keymaps:, command_host:)
4
8
  @command_registry = command_registry
5
9
  @ex_registry = ex_registry
@@ -32,6 +36,7 @@ module RuVim
32
36
  filetype = buffer.options["filetype"].to_s
33
37
  return nil if filetype.empty?
34
38
  return nil if buffer.options["__ftplugin_loaded__"] == filetype
39
+ return nil unless safe_filetype_name?(filetype)
35
40
 
36
41
  path = ftplugin_path_for(filetype)
37
42
  return nil unless path && File.file?(path)
@@ -58,11 +63,22 @@ module RuVim
58
63
 
59
64
  def xdg_ftplugin_path(filetype)
60
65
  base = ::ENV["XDG_CONFIG_HOME"]
61
- if base && !base.empty?
62
- File.join(base, "ruvim", "ftplugin", "#{filetype}.rb")
63
- else
64
- File.expand_path("~/.config/ruvim/ftplugin/#{filetype}.rb")
65
- end
66
+ root =
67
+ if base && !base.empty?
68
+ File.join(base, "ruvim", "ftplugin")
69
+ else
70
+ File.expand_path("~/.config/ruvim/ftplugin")
71
+ end
72
+
73
+ candidate = File.expand_path(File.join(root, "#{filetype}.rb"))
74
+ root_prefix = File.join(File.expand_path(root), "")
75
+ return nil unless candidate.start_with?(root_prefix)
76
+
77
+ candidate
78
+ end
79
+
80
+ def safe_filetype_name?(filetype)
81
+ SAFE_FILETYPE_RE.match?(filetype.to_s)
66
82
  end
67
83
  end
68
84
  end
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
@@ -15,12 +17,5 @@ module RuVim
15
17
  editor.current_buffer
16
18
  end
17
19
 
18
- def count
19
- invocation&.count || 1
20
- end
21
-
22
- def bang?
23
- invocation&.bang || false
24
- end
25
20
  end
26
21
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "shellwords"
2
4
 
3
5
  module RuVim
@@ -19,26 +21,50 @@ module RuVim
19
21
  end
20
22
 
21
23
  def dispatch_ex(editor, line)
22
- if (sub = parse_global_substitute(line))
23
- invocation = CommandInvocation.new(id: "__substitute__", kwargs: sub)
24
+ raw = line.to_s.strip
25
+ if raw.start_with?("!")
26
+ command = raw[1..].to_s.strip
27
+ invocation = CommandInvocation.new(id: "__shell__", argv: [command])
28
+ ctx = Context.new(editor:, invocation:)
29
+ @command_host.ex_shell(ctx, command:)
30
+ editor.enter_normal_mode
31
+ return
32
+ end
33
+
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:)
24
45
  ctx = Context.new(editor:, invocation:)
25
- @command_host.ex_substitute(ctx, **sub)
46
+ @command_host.ex_substitute(ctx, **kwargs)
26
47
  editor.enter_normal_mode
27
48
  return
28
49
  end
29
50
 
30
- parsed = parse_ex(line)
51
+ parsed = parse_ex(rest)
31
52
  return if parsed.nil?
32
53
 
33
54
  spec = @ex_registry.fetch(parsed.name)
34
55
  validate_ex_args!(spec, parsed.argv, parsed.bang)
35
56
  invocation = CommandInvocation.new(id: spec.name, argv: parsed.argv, bang: parsed.bang)
36
57
  ctx = Context.new(editor:, invocation:)
37
- @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)
38
64
  rescue StandardError => e
39
65
  editor.echo_error("Error: #{e.message}")
40
66
  ensure
41
- editor.enter_normal_mode
67
+ editor.leave_command_line if editor.mode == :command_line
42
68
  end
43
69
 
44
70
  def parse_ex(line)
@@ -56,28 +82,142 @@ module RuVim
56
82
  raise RuVim::CommandError, "Parse error: #{e.message}"
57
83
  end
58
84
 
59
- 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)
60
88
  raw = line.to_s.strip
61
- return nil unless raw.start_with?("%s")
62
- return nil if raw.length < 4
89
+ return nil unless raw.match?(/\As[^a-zA-Z]/)
90
+ return nil if raw.length < 2
63
91
 
64
- delim = raw[2]
92
+ delim = raw[1]
65
93
  return nil if delim.nil? || delim =~ /\s/
66
- i = 3
94
+ i = 2
67
95
  pat, i = parse_delimited_segment(raw, i, delim)
68
96
  return nil unless pat
69
97
  rep, i = parse_delimited_segment(raw, i, delim)
70
98
  return nil unless rep
71
- flags = raw[i..].to_s
99
+ flags_str = raw[i..].to_s
72
100
  {
73
101
  pattern: pat,
74
102
  replacement: rep,
75
- global: flags.include?("g")
103
+ flags_str: flags_str
76
104
  }
77
105
  rescue StandardError
78
106
  nil
79
107
  end
80
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
+
81
221
  private
82
222
 
83
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,26 @@ module RuVim
11
13
  end
12
14
 
13
15
  code = ch.ord
16
+ return 1 if code <= 0xA0 && !code.zero?
17
+ return cached_codepoint_width(code) if codepoint_cacheable?(code)
18
+
19
+ uncached_codepoint_width(code)
20
+ end
21
+
22
+ def codepoint_cacheable?(code)
23
+ !code.nil? && !code.zero?
24
+ end
25
+
26
+ def cached_codepoint_width(code)
27
+ aw = ambiguous_width
28
+ @codepoint_width_cache ||= {}
29
+ key = [code, aw]
30
+ return @codepoint_width_cache[key] if @codepoint_width_cache.key?(key)
31
+
32
+ @codepoint_width_cache[key] = uncached_codepoint_width(code)
33
+ end
34
+
35
+ def uncached_codepoint_width(code)
14
36
  return 0 if code.zero?
15
37
  return 0 if combining_mark?(code)
16
38
  return 0 if zero_width_codepoint?(code)
@@ -102,9 +124,13 @@ module RuVim
102
124
 
103
125
  def ambiguous_width
104
126
  env = ::ENV["RUVIM_AMBIGUOUS_WIDTH"]
105
- return 2 if env == "2"
127
+ if !defined?(@ambiguous_width_cached) || @ambiguous_width_env != env
128
+ @ambiguous_width_env = env
129
+ @ambiguous_width_cached = (env == "2" ? 2 : 1)
130
+ @codepoint_width_cache = {}
131
+ end
106
132
 
107
- 1
133
+ @ambiguous_width_cached
108
134
  end
109
135
  end
110
136
  end