ruvim 0.2.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 (63) 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 +23 -0
  6. data/docs/command.md +85 -0
  7. data/docs/config.md +2 -2
  8. data/docs/done.md +21 -0
  9. data/docs/spec.md +157 -12
  10. data/docs/todo.md +1 -5
  11. data/docs/vim_diff.md +94 -172
  12. data/lib/ruvim/app.rb +882 -69
  13. data/lib/ruvim/buffer.rb +35 -1
  14. data/lib/ruvim/cli.rb +12 -3
  15. data/lib/ruvim/clipboard.rb +2 -0
  16. data/lib/ruvim/command_invocation.rb +3 -1
  17. data/lib/ruvim/command_line.rb +2 -0
  18. data/lib/ruvim/command_registry.rb +2 -0
  19. data/lib/ruvim/config_dsl.rb +2 -0
  20. data/lib/ruvim/config_loader.rb +2 -0
  21. data/lib/ruvim/context.rb +2 -0
  22. data/lib/ruvim/dispatcher.rb +143 -13
  23. data/lib/ruvim/display_width.rb +3 -0
  24. data/lib/ruvim/editor.rb +455 -71
  25. data/lib/ruvim/ex_command_registry.rb +2 -0
  26. data/lib/ruvim/global_commands.rb +890 -63
  27. data/lib/ruvim/highlighter.rb +16 -21
  28. data/lib/ruvim/input.rb +39 -28
  29. data/lib/ruvim/keymap_manager.rb +83 -0
  30. data/lib/ruvim/keyword_chars.rb +2 -0
  31. data/lib/ruvim/lang/base.rb +25 -0
  32. data/lib/ruvim/lang/csv.rb +18 -0
  33. data/lib/ruvim/lang/json.rb +18 -0
  34. data/lib/ruvim/lang/markdown.rb +170 -0
  35. data/lib/ruvim/lang/ruby.rb +236 -0
  36. data/lib/ruvim/lang/scheme.rb +44 -0
  37. data/lib/ruvim/lang/tsv.rb +19 -0
  38. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  39. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  40. data/lib/ruvim/rich_view.rb +93 -0
  41. data/lib/ruvim/screen.rb +503 -106
  42. data/lib/ruvim/terminal.rb +18 -1
  43. data/lib/ruvim/text_metrics.rb +2 -0
  44. data/lib/ruvim/version.rb +1 -1
  45. data/lib/ruvim/window.rb +2 -0
  46. data/lib/ruvim.rb +14 -0
  47. data/test/app_completion_test.rb +73 -0
  48. data/test/app_dot_repeat_test.rb +13 -0
  49. data/test/app_motion_test.rb +13 -0
  50. data/test/app_scenario_test.rb +729 -1
  51. data/test/app_startup_test.rb +187 -0
  52. data/test/arglist_test.rb +113 -0
  53. data/test/buffer_test.rb +49 -30
  54. data/test/dispatcher_test.rb +322 -0
  55. data/test/editor_register_test.rb +23 -0
  56. data/test/highlighter_test.rb +121 -0
  57. data/test/indent_test.rb +201 -0
  58. data/test/input_screen_integration_test.rb +40 -2
  59. data/test/markdown_renderer_test.rb +279 -0
  60. data/test/on_save_hook_test.rb +150 -0
  61. data/test/rich_view_test.rb +478 -0
  62. data/test/screen_test.rb +304 -0
  63. metadata +33 -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,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)