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
@@ -1,5 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  module Highlighter
5
+ KEYWORD_COLOR = "\e[36m"
6
+ STRING_COLOR = "\e[32m"
7
+ NUMBER_COLOR = "\e[33m"
8
+ COMMENT_COLOR = "\e[90m"
9
+ VARIABLE_COLOR = "\e[93m"
10
+ CONSTANT_COLOR = "\e[96m"
11
+
3
12
  module_function
4
13
 
5
14
  def color_columns(filetype, line)
@@ -9,32 +18,18 @@ module RuVim
9
18
 
10
19
  case ft
11
20
  when "ruby"
12
- ruby_color_columns(text)
21
+ Lang::Ruby.color_columns(text)
13
22
  when "json"
14
- json_color_columns(text)
23
+ Lang::Json.color_columns(text)
24
+ when "markdown"
25
+ Lang::Markdown.color_columns(text)
26
+ when "scheme"
27
+ Lang::Scheme.color_columns(text)
15
28
  else
16
29
  {}
17
30
  end
18
31
  end
19
32
 
20
- def ruby_color_columns(text)
21
- cols = {}
22
- apply_regex(cols, text, /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "\e[32m")
23
- apply_regex(cols, text, /\b(?:def|class|module|end|if|elsif|else|unless|case|when|do|while|until|begin|rescue|ensure|return|yield)\b/, "\e[36m")
24
- apply_regex(cols, text, /\b\d+(?:\.\d+)?\b/, "\e[33m")
25
- apply_regex(cols, text, /#.*\z/, "\e[90m", override: true)
26
- cols
27
- end
28
-
29
- def json_color_columns(text)
30
- cols = {}
31
- apply_regex(cols, text, /"(?:\\.|[^"\\])*"\s*(?=:)/, "\e[36m")
32
- apply_regex(cols, text, /"(?:\\.|[^"\\])*"/, "\e[32m")
33
- apply_regex(cols, text, /\b(?:true|false|null)\b/, "\e[35m")
34
- apply_regex(cols, text, /-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/, "\e[33m")
35
- cols
36
- end
37
-
38
33
  def apply_regex(cols, text, regex, color, override: false)
39
34
  text.to_enum(:scan, regex).each do
40
35
  m = Regexp.last_match
@@ -47,6 +42,6 @@ module RuVim
47
42
  end
48
43
  end
49
44
 
50
- module_function :ruby_color_columns, :json_color_columns, :apply_regex
45
+ module_function :apply_regex
51
46
  end
52
47
  end
data/lib/ruvim/input.rb CHANGED
@@ -1,41 +1,48 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Input
3
- def initialize(stdin: STDIN)
4
- @stdin = stdin
5
+ def initialize(input)
6
+ @input = input
5
7
  end
6
8
 
7
9
  def read_key(timeout: nil, wakeup_ios: [], esc_timeout: nil)
8
- ios = [@stdin, *wakeup_ios].compact
10
+ ios = [@input, *wakeup_ios].compact
9
11
  readable = IO.select(ios, nil, nil, timeout)
10
12
  return nil unless readable
11
13
 
12
14
  ready = readable[0]
13
- wakeups = ready - [@stdin]
15
+ wakeups = ready - [@input]
14
16
  wakeups.each { |io| drain_io(io) }
15
- return nil unless ready.include?(@stdin)
16
-
17
- ch = @stdin.getch
18
- return :ctrl_b if ch == "\u0002"
19
- return :ctrl_c if ch == "\u0003"
20
- return :ctrl_d if ch == "\u0004"
21
- return :ctrl_e if ch == "\u0005"
22
- return :ctrl_f if ch == "\u0006"
23
- return :ctrl_i if ch == "\u0009"
24
- return :ctrl_l if ch == "\u000c"
25
- return :ctrl_n if ch == "\u000e"
26
- return :ctrl_o if ch == "\u000f"
27
- return :ctrl_p if ch == "\u0010"
28
- return :ctrl_r if ch == "\u0012"
29
- return :ctrl_u if ch == "\u0015"
30
- return :ctrl_v if ch == "\u0016"
31
- return :ctrl_w if ch == "\u0017"
32
- return :ctrl_y if ch == "\u0019"
33
- return :enter if ch == "\r" || ch == "\n"
34
- return :backspace if ch == "\u007f" || ch == "\b"
17
+ return nil unless ready.include?(@input)
35
18
 
36
- return read_escape_sequence(timeout: esc_timeout) if ch == "\e"
19
+ ch = @input.getch
20
+ case ch
21
+ when "\u0002" then :ctrl_b
22
+ when "\u0003" then :ctrl_c
23
+ when "\u0004" then :ctrl_d
24
+ when "\u0005" then :ctrl_e
25
+ when "\u0006" then :ctrl_f
26
+ when "\u0009" then :ctrl_i
27
+ when "\u000c" then :ctrl_l
28
+ when "\u000e" then :ctrl_n
29
+ when "\u000f" then :ctrl_o
30
+ when "\u0010" then :ctrl_p
31
+ when "\u0012" then :ctrl_r
32
+ when "\u0015" then :ctrl_u
33
+ when "\u0016" then :ctrl_v
34
+ when "\u0017" then :ctrl_w
35
+ when "\u0019" then :ctrl_y
36
+ when "\u001a" then :ctrl_z
37
+ when "\r", "\n" then :enter
38
+ when "\u007f", "\b" then :backspace
39
+ when "\e" then read_escape_sequence(timeout: esc_timeout)
40
+ else ch
41
+ end
42
+ end
37
43
 
38
- ch
44
+ def has_pending_input?
45
+ IO.select([@input], nil, nil, 0) != nil
39
46
  end
40
47
 
41
48
  private
@@ -55,13 +62,17 @@ module RuVim
55
62
  "[B" => :down,
56
63
  "[C" => :right,
57
64
  "[D" => :left,
65
+ "[1;2A" => :shift_up,
66
+ "[1;2B" => :shift_down,
67
+ "[1;2C" => :shift_right,
68
+ "[1;2D" => :shift_left,
58
69
  "[5~" => :pageup,
59
70
  "[6~" => :pagedown
60
71
  }
61
72
  wait = timeout.nil? ? 0.005 : [timeout.to_f, 0.0].max
62
73
  begin
63
- while IO.select([@stdin], nil, nil, wait)
64
- extra << @stdin.read_nonblock(1)
74
+ while IO.select([@input], nil, nil, wait)
75
+ extra << @input.read_nonblock(1)
65
76
  key = recognized[extra]
66
77
  return key if key
67
78
  end
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class KeymapManager
3
5
  Match = Struct.new(:status, :invocation, keyword_init: true)
6
+ BindingEntry = Struct.new(:layer, :mode, :tokens, :id, :argv, :kwargs, :bang, :scope, keyword_init: true)
4
7
 
5
8
  def initialize
6
9
  @mode_maps = Hash.new { |h, k| h[k] = {} }
@@ -44,6 +47,25 @@ module RuVim
44
47
  resolve_layers(layers, pending_tokens)
45
48
  end
46
49
 
50
+ def binding_entries_for_context(editor, mode: nil)
51
+ buffer = editor.current_buffer
52
+ filetype = detect_filetype(buffer)
53
+ modes = normalized_mode_filter(mode)
54
+ entries = []
55
+
56
+ entries.concat(snapshot_plain_layer(@buffer_maps[buffer.id], layer: :buffer))
57
+
58
+ if filetype && @filetype_maps.key?(filetype)
59
+ ft_modes = @filetype_maps[filetype]
60
+ entries.concat(snapshot_mode_layers(ft_modes, layer: :filetype, modes:))
61
+ end
62
+
63
+ entries.concat(snapshot_mode_layers(@mode_maps, layer: :app, modes:, scope: :mode))
64
+ entries.concat(snapshot_plain_layer(@global_map, layer: :app, scope: :global))
65
+
66
+ entries
67
+ end
68
+
47
69
  private
48
70
 
49
71
  def build_invocation(id, argv:, kwargs:, bang:, tokens:)
@@ -73,6 +95,67 @@ module RuVim
73
95
  Match.new(status: has_prefix ? :pending : :none)
74
96
  end
75
97
 
98
+ def snapshot_plain_layer(layer_map, layer:, scope: nil)
99
+ return [] unless layer_map && !layer_map.empty?
100
+
101
+ layer_map.map do |tokens, inv|
102
+ snapshot_entry(tokens, inv, layer:, scope:)
103
+ end.sort_by { |e| [token_sort_key(e.tokens), e.id.to_s] }
104
+ end
105
+
106
+ def snapshot_mode_layers(mode_maps, layer:, modes: nil, scope: nil)
107
+ return [] unless mode_maps
108
+
109
+ selected = mode_maps.keys.map(&:to_sym)
110
+ selected &= modes if modes
111
+ selected.sort_by! { |m| mode_sort_key(m) }
112
+
113
+ selected.flat_map do |m|
114
+ next [] if mode_maps[m].nil? || mode_maps[m].empty?
115
+
116
+ mode_maps[m].map do |tokens, inv|
117
+ snapshot_entry(tokens, inv, layer:, mode: m, scope:)
118
+ end.sort_by { |e| [token_sort_key(e.tokens), e.id.to_s] }
119
+ end
120
+ end
121
+
122
+ def snapshot_entry(tokens, inv, layer:, mode: nil, scope: nil)
123
+ BindingEntry.new(
124
+ layer: layer,
125
+ mode: mode,
126
+ tokens: Array(tokens).map(&:dup),
127
+ id: inv.id.to_s,
128
+ argv: Array(inv.argv).map { |v| v.is_a?(String) ? v.dup : v },
129
+ kwargs: (inv.kwargs || {}).dup,
130
+ bang: !!inv.bang,
131
+ scope: scope
132
+ )
133
+ end
134
+
135
+ def normalized_mode_filter(mode)
136
+ return nil if mode.nil?
137
+
138
+ ary = Array(mode).compact.map { |m| m.to_sym }
139
+ ary.empty? ? nil : ary
140
+ end
141
+
142
+ def token_sort_key(tokens)
143
+ Array(tokens).join("\0")
144
+ end
145
+
146
+ def mode_sort_key(mode)
147
+ order = {
148
+ normal: 0,
149
+ insert: 1,
150
+ visual_char: 2,
151
+ visual_line: 3,
152
+ visual_block: 4,
153
+ operator_pending: 5,
154
+ command_line: 6
155
+ }
156
+ [order.fetch(mode.to_sym, 99), mode.to_s]
157
+ end
158
+
76
159
  def detect_filetype(buffer)
77
160
  ft = buffer.options["filetype"] if buffer.respond_to?(:options)
78
161
  return ft if ft && !ft.empty?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  module KeywordChars
3
5
  module_function
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Base
6
+ module_function
7
+
8
+ def indent_trigger?(_line)
9
+ false
10
+ end
11
+
12
+ def dedent_trigger(_char)
13
+ nil
14
+ end
15
+
16
+ def calculate_indent(_lines, _target_row, _shiftwidth)
17
+ nil
18
+ end
19
+
20
+ def on_save(_ctx, _path)
21
+ # no-op
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Csv
6
+ module_function
7
+
8
+ # Detect CSV from buffer content: commas > 0 (and not TSV)
9
+ def detect?(buffer)
10
+ sample = (0...[buffer.line_count, 20].min).map { |i| buffer.line_at(i) }
11
+ commas = sample.sum { |l| l.count(",") }
12
+ commas > 0
13
+ end
14
+ end
15
+ end
16
+
17
+ RichView.register("csv", RichView::TableRenderer, detector: Lang::Csv.method(:detect?))
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Json
6
+ module_function
7
+
8
+ def color_columns(text)
9
+ cols = {}
10
+ Highlighter.apply_regex(cols, text, /"(?:\\.|[^"\\])*"\s*(?=:)/, "\e[36m")
11
+ Highlighter.apply_regex(cols, text, /"(?:\\.|[^"\\])*"/, "\e[32m")
12
+ Highlighter.apply_regex(cols, text, /\b(?:true|false|null)\b/, "\e[35m")
13
+ Highlighter.apply_regex(cols, text, /-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/, "\e[33m")
14
+ cols
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Markdown
6
+ # --- Regex patterns ---
7
+
8
+ HEADING_RE = /\A(\s*)(\#{1,6})\s/
9
+ FENCE_RE = /\A(`{3,}|~{3,})/
10
+ HR_RE = /\A(\-{3,}|\*{3,}|_{3,})\s*\z/
11
+ BLOCK_QUOTE_RE = /\A\s*> /
12
+ TABLE_LINE_RE = /\A\s*\|.*\|\s*\z/
13
+ TABLE_SEPARATOR_RE = /\A\|[\s\-:|]+\|\z/
14
+
15
+ BOLD_RE = /\*\*([^*]+)\*\*/
16
+ ITALIC_RE = /(?<!\*)\*([^*]+)\*(?!\*)/
17
+ INLINE_CODE_RE = /`([^`]+)`/
18
+ LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/
19
+ CHECKBOX_CHECKED_RE = /^(\s*-\s*)\[x\]/
20
+ CHECKBOX_UNCHECKED_RE = /^(\s*-\s*)\[ \]/
21
+
22
+ # --- Heading styles (for color_columns) ---
23
+
24
+ HEADING_COLORS = {
25
+ 1 => "\e[1;33m", # bold yellow
26
+ 2 => "\e[1;36m", # bold cyan
27
+ 3 => "\e[1;32m", # bold green
28
+ 4 => "\e[1;35m", # bold magenta
29
+ 5 => "\e[1;34m", # bold blue
30
+ 6 => "\e[1;90m" # bold dim
31
+ }.freeze
32
+
33
+ # --- FenceState: tracks code fence open/close across lines ---
34
+
35
+ class FenceState
36
+ attr_reader :in_code_block, :fence_marker
37
+
38
+ def initialize
39
+ @in_code_block = false
40
+ @fence_marker = nil
41
+ end
42
+
43
+ def scan_line(line)
44
+ stripped = line.to_s.strip
45
+ if @in_code_block
46
+ if fence_close?(stripped)
47
+ @in_code_block = false
48
+ @fence_marker = nil
49
+ end
50
+ else
51
+ marker = fence_open(stripped)
52
+ if marker
53
+ @in_code_block = true
54
+ @fence_marker = marker
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def fence_open(stripped)
62
+ if (m = stripped.match(/\A(`{3,})(.*)\z/))
63
+ m[1]
64
+ elsif (m = stripped.match(/\A(~{3,})(.*)\z/))
65
+ m[1]
66
+ end
67
+ end
68
+
69
+ def fence_close?(stripped)
70
+ return false unless @fence_marker
71
+ if @fence_marker.start_with?("`")
72
+ stripped.match?(/\A`{#{@fence_marker.length},}\s*\z/)
73
+ else
74
+ stripped.match?(/\A~{#{@fence_marker.length},}\s*\z/)
75
+ end
76
+ end
77
+ end
78
+
79
+ # --- Detection helpers ---
80
+
81
+ module_function
82
+
83
+ def heading?(line)
84
+ line.to_s.match?(HEADING_RE)
85
+ end
86
+
87
+ def heading_level(line)
88
+ m = line.to_s.match(HEADING_RE)
89
+ m ? m[2].length : 0
90
+ end
91
+
92
+ def fence_line?(stripped)
93
+ stripped.to_s.match?(FENCE_RE)
94
+ end
95
+
96
+ def horizontal_rule?(stripped)
97
+ stripped.to_s.match?(HR_RE)
98
+ end
99
+
100
+ def block_quote?(line)
101
+ line.to_s.match?(BLOCK_QUOTE_RE)
102
+ end
103
+
104
+ def table_line?(line)
105
+ stripped = line.to_s.strip
106
+ stripped.start_with?("|") && stripped.end_with?("|") && stripped.length > 1
107
+ end
108
+
109
+ def table_separator?(stripped)
110
+ stripped.to_s.match?(TABLE_SEPARATOR_RE)
111
+ end
112
+
113
+ def parse_table_cells(line)
114
+ stripped = line.to_s.strip
115
+ inner = stripped[1...-1] || ""
116
+ inner.split("|", -1).map(&:strip)
117
+ end
118
+
119
+ # --- Syntax highlight: color_columns ---
120
+
121
+ def color_columns(text)
122
+ cols = {}
123
+ return cols if text.nil? || text.empty?
124
+
125
+ stripped = text.strip
126
+
127
+ # Fence line: entire line dim
128
+ if fence_line?(stripped)
129
+ fill_line(cols, text, "\e[90m")
130
+ return cols
131
+ end
132
+
133
+ # HR: entire line dim
134
+ if horizontal_rule?(stripped)
135
+ fill_line(cols, text, "\e[90m")
136
+ return cols
137
+ end
138
+
139
+ # Heading: entire line colored by level
140
+ if (m = text.match(HEADING_RE))
141
+ level = m[2].length
142
+ color = HEADING_COLORS[level] || HEADING_COLORS[6]
143
+ fill_line(cols, text, color)
144
+ return cols
145
+ end
146
+
147
+ # Block quote marker
148
+ if (m = text.match(/\A(\s*>)/))
149
+ Highlighter.apply_regex(cols, text, /\A\s*>/, "\e[36m")
150
+ end
151
+
152
+ # Inline elements
153
+ Highlighter.apply_regex(cols, text, CHECKBOX_CHECKED_RE, "\e[32m")
154
+ Highlighter.apply_regex(cols, text, CHECKBOX_UNCHECKED_RE, "\e[90m")
155
+ Highlighter.apply_regex(cols, text, BOLD_RE, "\e[1m")
156
+ Highlighter.apply_regex(cols, text, ITALIC_RE, "\e[3m")
157
+ Highlighter.apply_regex(cols, text, INLINE_CODE_RE, "\e[33m")
158
+ Highlighter.apply_regex(cols, text, LINK_RE, "\e[4m")
159
+
160
+ cols
161
+ end
162
+
163
+ private
164
+
165
+ def self.fill_line(cols, text, color)
166
+ text.length.times { |i| cols[i] = color }
167
+ end
168
+ end
169
+ end
170
+ end