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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +4 -0
- data/AGENTS.md +84 -0
- data/CLAUDE.md +1 -0
- data/docs/binding.md +23 -0
- data/docs/command.md +85 -0
- data/docs/config.md +2 -2
- data/docs/done.md +21 -0
- data/docs/spec.md +157 -12
- data/docs/todo.md +1 -5
- data/docs/vim_diff.md +94 -172
- data/lib/ruvim/app.rb +882 -69
- data/lib/ruvim/buffer.rb +35 -1
- data/lib/ruvim/cli.rb +12 -3
- data/lib/ruvim/clipboard.rb +2 -0
- data/lib/ruvim/command_invocation.rb +3 -1
- data/lib/ruvim/command_line.rb +2 -0
- data/lib/ruvim/command_registry.rb +2 -0
- data/lib/ruvim/config_dsl.rb +2 -0
- data/lib/ruvim/config_loader.rb +2 -0
- data/lib/ruvim/context.rb +2 -0
- data/lib/ruvim/dispatcher.rb +143 -13
- data/lib/ruvim/display_width.rb +3 -0
- data/lib/ruvim/editor.rb +455 -71
- data/lib/ruvim/ex_command_registry.rb +2 -0
- data/lib/ruvim/global_commands.rb +890 -63
- data/lib/ruvim/highlighter.rb +16 -21
- data/lib/ruvim/input.rb +39 -28
- data/lib/ruvim/keymap_manager.rb +83 -0
- data/lib/ruvim/keyword_chars.rb +2 -0
- data/lib/ruvim/lang/base.rb +25 -0
- data/lib/ruvim/lang/csv.rb +18 -0
- data/lib/ruvim/lang/json.rb +18 -0
- data/lib/ruvim/lang/markdown.rb +170 -0
- data/lib/ruvim/lang/ruby.rb +236 -0
- data/lib/ruvim/lang/scheme.rb +44 -0
- data/lib/ruvim/lang/tsv.rb +19 -0
- data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
- data/lib/ruvim/rich_view/table_renderer.rb +176 -0
- data/lib/ruvim/rich_view.rb +93 -0
- data/lib/ruvim/screen.rb +503 -106
- data/lib/ruvim/terminal.rb +18 -1
- data/lib/ruvim/text_metrics.rb +2 -0
- data/lib/ruvim/version.rb +1 -1
- data/lib/ruvim/window.rb +2 -0
- data/lib/ruvim.rb +14 -0
- data/test/app_completion_test.rb +73 -0
- data/test/app_dot_repeat_test.rb +13 -0
- data/test/app_motion_test.rb +13 -0
- data/test/app_scenario_test.rb +729 -1
- data/test/app_startup_test.rb +187 -0
- data/test/arglist_test.rb +113 -0
- data/test/buffer_test.rb +49 -30
- data/test/dispatcher_test.rb +322 -0
- data/test/editor_register_test.rb +23 -0
- data/test/highlighter_test.rb +121 -0
- data/test/indent_test.rb +201 -0
- data/test/input_screen_integration_test.rb +40 -2
- data/test/markdown_renderer_test.rb +279 -0
- data/test/on_save_hook_test.rb +150 -0
- data/test/rich_view_test.rb +478 -0
- data/test/screen_test.rb +304 -0
- metadata +33 -2
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module RuVim
|
|
7
|
+
module Lang
|
|
8
|
+
module Ruby
|
|
9
|
+
PRISM_KEYWORD_TYPES = %i[
|
|
10
|
+
KEYWORD_ALIAS
|
|
11
|
+
KEYWORD_AND
|
|
12
|
+
KEYWORD_BEGIN
|
|
13
|
+
KEYWORD_BEGIN_UPCASE
|
|
14
|
+
KEYWORD_BREAK
|
|
15
|
+
KEYWORD_CASE
|
|
16
|
+
KEYWORD_CLASS
|
|
17
|
+
KEYWORD_DEF
|
|
18
|
+
KEYWORD_DEFINED
|
|
19
|
+
KEYWORD_DO
|
|
20
|
+
KEYWORD_ELSE
|
|
21
|
+
KEYWORD_ELSIF
|
|
22
|
+
KEYWORD_END
|
|
23
|
+
KEYWORD_ENSURE
|
|
24
|
+
KEYWORD_FALSE
|
|
25
|
+
KEYWORD_FOR
|
|
26
|
+
KEYWORD_IF
|
|
27
|
+
KEYWORD_IF_MODIFIER
|
|
28
|
+
KEYWORD_IN
|
|
29
|
+
KEYWORD_MODULE
|
|
30
|
+
KEYWORD_NEXT
|
|
31
|
+
KEYWORD_NIL
|
|
32
|
+
KEYWORD_NOT
|
|
33
|
+
KEYWORD_OR
|
|
34
|
+
KEYWORD_REDO
|
|
35
|
+
KEYWORD_RESCUE
|
|
36
|
+
KEYWORD_RESCUE_MODIFIER
|
|
37
|
+
KEYWORD_RETRY
|
|
38
|
+
KEYWORD_RETURN
|
|
39
|
+
KEYWORD_SELF
|
|
40
|
+
KEYWORD_SUPER
|
|
41
|
+
KEYWORD_THEN
|
|
42
|
+
KEYWORD_TRUE
|
|
43
|
+
KEYWORD_UNDEF
|
|
44
|
+
KEYWORD_UNLESS
|
|
45
|
+
KEYWORD_UNLESS_MODIFIER
|
|
46
|
+
KEYWORD_UNTIL
|
|
47
|
+
KEYWORD_UNTIL_MODIFIER
|
|
48
|
+
KEYWORD_WHEN
|
|
49
|
+
KEYWORD_WHILE
|
|
50
|
+
KEYWORD_WHILE_MODIFIER
|
|
51
|
+
KEYWORD_YIELD
|
|
52
|
+
MISSING
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
PRISM_STRING_TYPES = %i[
|
|
56
|
+
STRING_BEGIN
|
|
57
|
+
STRING_CONTENT
|
|
58
|
+
STRING_END
|
|
59
|
+
SYMBOL_BEGIN
|
|
60
|
+
REGEXP_BEGIN
|
|
61
|
+
REGEXP_CONTENT
|
|
62
|
+
REGEXP_END
|
|
63
|
+
XSTRING_BEGIN
|
|
64
|
+
XSTRING_CONTENT
|
|
65
|
+
XSTRING_END
|
|
66
|
+
WORDS_BEGIN
|
|
67
|
+
QWORDS_BEGIN
|
|
68
|
+
WORDS_SEPARATOR
|
|
69
|
+
STRING
|
|
70
|
+
CHARACTER_LITERAL
|
|
71
|
+
].freeze
|
|
72
|
+
|
|
73
|
+
PRISM_NUMBER_TYPES = %i[
|
|
74
|
+
INTEGER
|
|
75
|
+
FLOAT
|
|
76
|
+
RATIONAL_NUMBER
|
|
77
|
+
IMAGINARY_NUMBER
|
|
78
|
+
UINTEGER
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
PRISM_COMMENT_TYPES = %i[
|
|
82
|
+
COMMENT
|
|
83
|
+
EMBDOC_BEGIN
|
|
84
|
+
EMBDOC_LINE
|
|
85
|
+
EMBDOC_END
|
|
86
|
+
].freeze
|
|
87
|
+
|
|
88
|
+
PRISM_VARIABLE_TYPES = %i[
|
|
89
|
+
INSTANCE_VARIABLE
|
|
90
|
+
CLASS_VARIABLE
|
|
91
|
+
GLOBAL_VARIABLE
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
PRISM_CONSTANT_TYPES = %i[
|
|
95
|
+
CONSTANT
|
|
96
|
+
].freeze
|
|
97
|
+
|
|
98
|
+
# Keywords that open a new indentation level
|
|
99
|
+
INDENT_OPEN_RE = /\A\s*(?:def|class|module|if|unless|while|until|for|begin|case)\b/
|
|
100
|
+
# `do` at end of line (block form)
|
|
101
|
+
INDENT_DO_RE = /\bdo\s*(\|[^|]*\|)?\s*$/
|
|
102
|
+
# Opening brackets at end of line
|
|
103
|
+
INDENT_BRACKET_OPEN_RE = /[\[({]\s*$/
|
|
104
|
+
# Keywords that close an indentation level
|
|
105
|
+
INDENT_CLOSE_RE = /\A\s*(?:end|[}\])])/
|
|
106
|
+
# Keywords at same level as their opening keyword (dedent for the line itself)
|
|
107
|
+
INDENT_MID_RE = /\A\s*(?:else|elsif|when|rescue|ensure|in)\b/
|
|
108
|
+
# Modifier keywords that do NOT open indentation
|
|
109
|
+
MODIFIER_RE = /\b(?:if|unless|while|until|rescue)\b/
|
|
110
|
+
|
|
111
|
+
module_function
|
|
112
|
+
|
|
113
|
+
def calculate_indent(lines, target_row, shiftwidth)
|
|
114
|
+
depth = 0
|
|
115
|
+
(0...target_row).each do |row|
|
|
116
|
+
line = lines[row]
|
|
117
|
+
stripped = line.to_s.lstrip
|
|
118
|
+
|
|
119
|
+
# Skip blank and comment lines for indent computation
|
|
120
|
+
next if stripped.empty? || stripped.start_with?("#")
|
|
121
|
+
|
|
122
|
+
# Check if the line opens a new level
|
|
123
|
+
if stripped.match?(INDENT_OPEN_RE)
|
|
124
|
+
# Check for modifier form: something before the keyword on the same line
|
|
125
|
+
# e.g. "return if true" — if keyword is not at start, it's a modifier
|
|
126
|
+
first_word = stripped[/\A(\w+)/, 1]
|
|
127
|
+
if %w[if unless while until rescue].include?(first_word)
|
|
128
|
+
depth += 1
|
|
129
|
+
elsif %w[def class module begin case for].include?(first_word)
|
|
130
|
+
depth += 1
|
|
131
|
+
end
|
|
132
|
+
elsif stripped.match?(INDENT_DO_RE)
|
|
133
|
+
depth += 1
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Count opening brackets (not at end-of-line pattern, but individual)
|
|
137
|
+
stripped.each_char do |ch|
|
|
138
|
+
case ch
|
|
139
|
+
when "{", "[", "("
|
|
140
|
+
depth += 1
|
|
141
|
+
when "}", "]", ")"
|
|
142
|
+
depth -= 1
|
|
143
|
+
end
|
|
144
|
+
end if !stripped.match?(INDENT_OPEN_RE) && !stripped.match?(INDENT_DO_RE)
|
|
145
|
+
|
|
146
|
+
# Handle closing keywords
|
|
147
|
+
if stripped.match?(/\A\s*end\b/)
|
|
148
|
+
depth -= 1
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Handle mid keywords (else/elsif/when/rescue/ensure) — they don't change depth for following lines
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Now compute indent for target_row
|
|
155
|
+
target_line = lines[target_row].to_s.lstrip
|
|
156
|
+
|
|
157
|
+
# If target line is a closing keyword, dedent
|
|
158
|
+
if target_line.match?(INDENT_CLOSE_RE)
|
|
159
|
+
depth -= 1
|
|
160
|
+
elsif target_line.match?(INDENT_MID_RE)
|
|
161
|
+
depth -= 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
depth = 0 if depth < 0
|
|
165
|
+
depth * shiftwidth
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Returns true if the line should increase indent for the next line
|
|
169
|
+
def indent_trigger?(line)
|
|
170
|
+
stripped = line.to_s.rstrip.lstrip
|
|
171
|
+
first_word = stripped[/\A(\w+)/, 1].to_s
|
|
172
|
+
return true if %w[def class module if unless while until for begin case].include?(first_word)
|
|
173
|
+
return true if stripped.match?(/\bdo\s*(\|[^|]*\|)?\s*$/)
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Dedent trigger patterns keyed by the last character typed
|
|
178
|
+
DEDENT_TRIGGERS = {
|
|
179
|
+
"d" => /\A(\s*)end\z/,
|
|
180
|
+
"e" => /\A(\s*)(?:else|rescue|ensure)\z/,
|
|
181
|
+
"f" => /\A(\s*)elsif\z/,
|
|
182
|
+
"n" => /\A(\s*)(?:when|in)\z/
|
|
183
|
+
}.freeze
|
|
184
|
+
|
|
185
|
+
# Returns the dedent pattern for the given character, or nil
|
|
186
|
+
def dedent_trigger(char)
|
|
187
|
+
DEDENT_TRIGGERS[char]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def on_save(ctx, path)
|
|
191
|
+
return unless path && File.exist?(path)
|
|
192
|
+
output, status = Open3.capture2e("ruby", "-wc", path)
|
|
193
|
+
message = output.sub(/^Syntax OK\n?\z/m, "").strip
|
|
194
|
+
|
|
195
|
+
if !status.success? || !message.empty?
|
|
196
|
+
buffer_id = ctx.buffer.id
|
|
197
|
+
items = message.lines.filter_map { |line|
|
|
198
|
+
if line =~ /\A.+?:(\d+):/
|
|
199
|
+
{ buffer_id: buffer_id, row: $1.to_i - 1, col: 0, text: line.strip }
|
|
200
|
+
end
|
|
201
|
+
}
|
|
202
|
+
items = [{ buffer_id: buffer_id, row: 0, col: 0, text: message }] if items.empty?
|
|
203
|
+
ctx.editor.set_quickfix_list(items)
|
|
204
|
+
first = message.lines.first.to_s.strip
|
|
205
|
+
hint = items.size > 1 ? " (Q to see all, #{items.size} total)" : ""
|
|
206
|
+
ctx.editor.echo_error("#{first}#{hint}")
|
|
207
|
+
else
|
|
208
|
+
ctx.editor.set_quickfix_list([])
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def color_columns(text)
|
|
213
|
+
cols = {}
|
|
214
|
+
Prism.lex(text).value.each do |entry|
|
|
215
|
+
token = entry[0]
|
|
216
|
+
type = token.type
|
|
217
|
+
range = token.location.start_offset...token.location.end_offset
|
|
218
|
+
if PRISM_STRING_TYPES.include?(type)
|
|
219
|
+
range.each { |idx| cols[idx] = Highlighter::STRING_COLOR unless cols.key?(idx) }
|
|
220
|
+
elsif PRISM_KEYWORD_TYPES.include?(type)
|
|
221
|
+
range.each { |idx| cols[idx] = Highlighter::KEYWORD_COLOR unless cols.key?(idx) }
|
|
222
|
+
elsif PRISM_NUMBER_TYPES.include?(type)
|
|
223
|
+
range.each { |idx| cols[idx] = Highlighter::NUMBER_COLOR unless cols.key?(idx) }
|
|
224
|
+
elsif PRISM_VARIABLE_TYPES.include?(type)
|
|
225
|
+
range.each { |idx| cols[idx] = Highlighter::VARIABLE_COLOR unless cols.key?(idx) }
|
|
226
|
+
elsif PRISM_CONSTANT_TYPES.include?(type)
|
|
227
|
+
range.each { |idx| cols[idx] = Highlighter::CONSTANT_COLOR unless cols.key?(idx) }
|
|
228
|
+
elsif PRISM_COMMENT_TYPES.include?(type)
|
|
229
|
+
range.each { |idx| cols[idx] = Highlighter::COMMENT_COLOR }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
cols
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuVim
|
|
4
|
+
module Lang
|
|
5
|
+
module Scheme
|
|
6
|
+
KEYWORDS = %w[
|
|
7
|
+
define define-syntax define-record-type define-values define-library
|
|
8
|
+
lambda let let* letrec letrec* let-values let-syntax letrec-syntax
|
|
9
|
+
if cond case when unless and or not
|
|
10
|
+
begin do set! quote quasiquote unquote unquote-splicing
|
|
11
|
+
syntax-rules syntax-case
|
|
12
|
+
import export library
|
|
13
|
+
else =>
|
|
14
|
+
call-with-current-continuation call/cc
|
|
15
|
+
call-with-values values
|
|
16
|
+
dynamic-wind
|
|
17
|
+
guard raise raise-continuable with-exception-handler
|
|
18
|
+
parameterize make-parameter
|
|
19
|
+
include include-ci
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
KEYWORD_RE = /(?<=[\s(])(#{KEYWORDS.map { |k| Regexp.escape(k) }.join("|")})(?=[\s()\]]|$)/
|
|
23
|
+
COMMENT_RE = /;.*/
|
|
24
|
+
STRING_RE = /"(?:\\.|[^"\\])*"/
|
|
25
|
+
CHAR_RE = /#\\(?:space|newline|tab|alarm|backspace|delete|escape|null|return|[^\s()])/
|
|
26
|
+
BOOLEAN_RE = /#[tf]\b/
|
|
27
|
+
NUMBER_RE = /(?<=[\s(])[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?=[\s()\]]|$)/
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def color_columns(text)
|
|
32
|
+
cols = {}
|
|
33
|
+
# Order matters: comment overrides all, then strings, then others
|
|
34
|
+
Highlighter.apply_regex(cols, text, CHAR_RE, Highlighter::STRING_COLOR)
|
|
35
|
+
Highlighter.apply_regex(cols, text, STRING_RE, Highlighter::STRING_COLOR)
|
|
36
|
+
Highlighter.apply_regex(cols, text, KEYWORD_RE, Highlighter::KEYWORD_COLOR)
|
|
37
|
+
Highlighter.apply_regex(cols, text, BOOLEAN_RE, "\e[35m")
|
|
38
|
+
Highlighter.apply_regex(cols, text, NUMBER_RE, Highlighter::NUMBER_COLOR)
|
|
39
|
+
Highlighter.apply_regex(cols, text, COMMENT_RE, Highlighter::COMMENT_COLOR, override: true)
|
|
40
|
+
cols
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuVim
|
|
4
|
+
module Lang
|
|
5
|
+
module Tsv
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Detect TSV from buffer content: tabs >= commas and tabs > 0
|
|
9
|
+
def detect?(buffer)
|
|
10
|
+
sample = (0...[buffer.line_count, 20].min).map { |i| buffer.line_at(i) }
|
|
11
|
+
tabs = sample.sum { |l| l.count("\t") }
|
|
12
|
+
commas = sample.sum { |l| l.count(",") }
|
|
13
|
+
tabs > 0 && tabs >= commas
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RichView.register("tsv", RichView::TableRenderer, detector: Lang::Tsv.method(:detect?))
|
|
19
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuVim
|
|
4
|
+
module RichView
|
|
5
|
+
module MarkdownRenderer
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def delimiter_for(_format)
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def needs_pre_context?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render_visible(lines, delimiter:, context: {})
|
|
17
|
+
return lines if lines.nil? || lines.empty?
|
|
18
|
+
|
|
19
|
+
state = Lang::Markdown::FenceState.new
|
|
20
|
+
pre = context[:pre_context_lines]
|
|
21
|
+
if pre
|
|
22
|
+
pre.each { |l| state.scan_line(l) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Identify table groups for column-width alignment
|
|
26
|
+
table_groups = identify_table_groups(lines)
|
|
27
|
+
|
|
28
|
+
lines.each_with_index.map do |line, idx|
|
|
29
|
+
rendered = render_line(line, state, table_groups[idx])
|
|
30
|
+
state.scan_line(line)
|
|
31
|
+
rendered
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cursor_display_col(raw_line, cursor_x, visible_lines:, delimiter:)
|
|
36
|
+
if Lang::Markdown.table_line?(raw_line)
|
|
37
|
+
table_lines = visible_lines.select { |l| Lang::Markdown.table_line?(l) }
|
|
38
|
+
group = build_table_group(table_lines)
|
|
39
|
+
if group
|
|
40
|
+
return table_cursor_display_col(raw_line, cursor_x, group)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
RuVim::TextMetrics.screen_col_for_char_index(raw_line, cursor_x)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- Line rendering ---
|
|
47
|
+
|
|
48
|
+
def render_line(line, state, table_group)
|
|
49
|
+
stripped = line.to_s.strip
|
|
50
|
+
|
|
51
|
+
# Code fence open/close
|
|
52
|
+
if !state.in_code_block && Lang::Markdown.fence_line?(stripped)
|
|
53
|
+
return "\e[90m#{line}\e[m"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if state.in_code_block
|
|
57
|
+
if Lang::Markdown.fence_line?(stripped)
|
|
58
|
+
return "\e[90m#{line}\e[m"
|
|
59
|
+
end
|
|
60
|
+
return "\e[38;5;223m#{line}\e[m"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# HR
|
|
64
|
+
if Lang::Markdown.horizontal_rule?(stripped)
|
|
65
|
+
return render_hr(line)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Heading
|
|
69
|
+
level = Lang::Markdown.heading_level(line)
|
|
70
|
+
if level > 0
|
|
71
|
+
return render_heading(line, level)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Block quote
|
|
75
|
+
if Lang::Markdown.block_quote?(line)
|
|
76
|
+
return "\e[36m#{apply_inline(line)}\e[m"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Table
|
|
80
|
+
if table_group
|
|
81
|
+
return render_table_line(line, stripped, table_group)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Default: inline decoration only
|
|
85
|
+
apply_inline(line)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --- Heading rendering ---
|
|
89
|
+
|
|
90
|
+
HEADING_STYLES = Lang::Markdown::HEADING_COLORS
|
|
91
|
+
|
|
92
|
+
def render_heading(line, level)
|
|
93
|
+
style = HEADING_STYLES[level] || HEADING_STYLES[6]
|
|
94
|
+
"#{style}#{line}\e[m"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# --- HR rendering ---
|
|
98
|
+
|
|
99
|
+
def render_hr(line)
|
|
100
|
+
# Replace the HR marker with box-drawing horizontal line
|
|
101
|
+
width = [line.length, 3].max
|
|
102
|
+
"\e[90m#{"─" * width}\e[m"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Inline decoration ---
|
|
106
|
+
|
|
107
|
+
def apply_inline(line)
|
|
108
|
+
result = line.to_s.dup
|
|
109
|
+
|
|
110
|
+
# Checkbox (must come before bold/italic to avoid interference)
|
|
111
|
+
result = result.gsub(Lang::Markdown::CHECKBOX_CHECKED_RE) { "#{$1}\e[32m[x]\e[m" }
|
|
112
|
+
result = result.gsub(Lang::Markdown::CHECKBOX_UNCHECKED_RE) { "#{$1}\e[90m[ ]\e[m" }
|
|
113
|
+
|
|
114
|
+
# Bold **text**
|
|
115
|
+
result = result.gsub(Lang::Markdown::BOLD_RE) { "\e[1m**#{$1}**\e[22m" }
|
|
116
|
+
|
|
117
|
+
# Italic *text* (but not ** which is bold)
|
|
118
|
+
result = result.gsub(Lang::Markdown::ITALIC_RE) { "\e[3m*#{$1}*\e[23m" }
|
|
119
|
+
|
|
120
|
+
# Inline code `text`
|
|
121
|
+
result = result.gsub(Lang::Markdown::INLINE_CODE_RE) { "\e[33m`#{$1}`\e[m" }
|
|
122
|
+
|
|
123
|
+
# Links [text](url)
|
|
124
|
+
result = result.gsub(Lang::Markdown::LINK_RE) { "\e[4m#{$1}\e[24m(\e[2m#{$2}\e[22m)" }
|
|
125
|
+
|
|
126
|
+
result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# --- Table rendering ---
|
|
130
|
+
|
|
131
|
+
def identify_table_groups(lines)
|
|
132
|
+
groups = {}
|
|
133
|
+
i = 0
|
|
134
|
+
while i < lines.length
|
|
135
|
+
if Lang::Markdown.table_line?(lines[i])
|
|
136
|
+
start = i
|
|
137
|
+
table_lines = []
|
|
138
|
+
while i < lines.length && Lang::Markdown.table_line?(lines[i])
|
|
139
|
+
table_lines << lines[i]
|
|
140
|
+
i += 1
|
|
141
|
+
end
|
|
142
|
+
group = build_table_group(table_lines)
|
|
143
|
+
(start...(start + table_lines.length)).each { |j| groups[j] = group }
|
|
144
|
+
else
|
|
145
|
+
i += 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
groups
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_table_group(table_lines)
|
|
152
|
+
return nil if table_lines.nil? || table_lines.empty?
|
|
153
|
+
|
|
154
|
+
cells = table_lines.map { |l| Lang::Markdown.parse_table_cells(l) }
|
|
155
|
+
max_cols = cells.map(&:length).max
|
|
156
|
+
return nil if max_cols.nil? || max_cols.zero?
|
|
157
|
+
|
|
158
|
+
col_widths = Array.new(max_cols, 0)
|
|
159
|
+
cells.each do |row|
|
|
160
|
+
row.each_with_index do |cell, ci|
|
|
161
|
+
w = RuVim::DisplayWidth.display_width(cell)
|
|
162
|
+
col_widths[ci] = w if w > col_widths[ci]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
{ col_widths: col_widths, max_cols: max_cols }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def render_table_line(line, stripped, group)
|
|
170
|
+
if Lang::Markdown.table_separator?(stripped)
|
|
171
|
+
render_table_separator(group)
|
|
172
|
+
else
|
|
173
|
+
render_table_data_row(line, group)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render_table_separator(group)
|
|
178
|
+
col_widths = group[:col_widths]
|
|
179
|
+
cells = col_widths.map { |w| "─" * (w + 2) }
|
|
180
|
+
"├#{cells.join("┼")}┤"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def render_table_data_row(line, group)
|
|
184
|
+
col_widths = group[:col_widths]
|
|
185
|
+
cells = Lang::Markdown.parse_table_cells(line)
|
|
186
|
+
padded = cells.each_with_index.map do |cell, i|
|
|
187
|
+
target = col_widths[i] || 0
|
|
188
|
+
pad_cell(cell, target)
|
|
189
|
+
end
|
|
190
|
+
# Fill missing columns
|
|
191
|
+
(cells.length...group[:max_cols]).each do |i|
|
|
192
|
+
padded << " " * (col_widths[i] || 0)
|
|
193
|
+
end
|
|
194
|
+
"│ #{padded.join(" │ ")} │"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def pad_cell(cell, target_width)
|
|
198
|
+
current = RuVim::DisplayWidth.display_width(cell)
|
|
199
|
+
gap = target_width - current
|
|
200
|
+
gap > 0 ? "#{cell}#{" " * gap}" : cell
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- Table cursor mapping ---
|
|
204
|
+
|
|
205
|
+
def table_cursor_display_col(raw_line, cursor_x, group)
|
|
206
|
+
col_widths = group[:col_widths]
|
|
207
|
+
cells = Lang::Markdown.parse_table_cells(raw_line)
|
|
208
|
+
|
|
209
|
+
# Map cursor_x (in raw line) to display col in formatted line
|
|
210
|
+
# Raw line: "| cell1 | cell2 |"
|
|
211
|
+
# Formatted: "│ cell1 │ cell2 │"
|
|
212
|
+
|
|
213
|
+
# Walk through the raw line to find which cell cursor_x is in
|
|
214
|
+
pos = 0
|
|
215
|
+
raw = raw_line.to_s
|
|
216
|
+
# Skip leading whitespace
|
|
217
|
+
pos += 1 while pos < raw.length && raw[pos] == " "
|
|
218
|
+
# Skip leading |
|
|
219
|
+
pos += 1 if pos < raw.length && raw[pos] == "|"
|
|
220
|
+
|
|
221
|
+
display_col = 2 # "│ " prefix
|
|
222
|
+
cells.each_with_index do |cell, ci|
|
|
223
|
+
# Skip whitespace before cell content
|
|
224
|
+
pos += 1 while pos < raw.length && raw[pos] == " "
|
|
225
|
+
|
|
226
|
+
cell_start = pos
|
|
227
|
+
cell_end = cell_start + cell.length
|
|
228
|
+
|
|
229
|
+
if cursor_x < cell_end || ci == cells.length - 1
|
|
230
|
+
# Cursor is in this cell
|
|
231
|
+
offset_in_cell = [cursor_x - cell_start, 0].max
|
|
232
|
+
offset_in_cell = [offset_in_cell, cell.length].min
|
|
233
|
+
return display_col + RuVim::DisplayWidth.display_width(cell[0...offset_in_cell])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
display_col += (col_widths[ci] || 0) + 3 # " │ "
|
|
237
|
+
pos = cell_end
|
|
238
|
+
# Skip whitespace after cell
|
|
239
|
+
pos += 1 while pos < raw.length && raw[pos] == " "
|
|
240
|
+
# Skip |
|
|
241
|
+
pos += 1 if pos < raw.length && raw[pos] == "|"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
display_col
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|