rich-ruby 1.0.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.
@@ -0,0 +1,509 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+ require_relative "segment"
5
+ require_relative "text"
6
+ require_relative "panel"
7
+ require_relative "box"
8
+ require_relative "cells"
9
+
10
+ module Rich
11
+ # Markdown rendering for terminal output.
12
+ # Parses and renders Markdown content with styling.
13
+ class Markdown
14
+ # Default styles for Markdown elements
15
+ DEFAULT_STYLES = {
16
+ # Headings
17
+ h1: Style.new(color: Color.parse("bright_cyan"), bold: true),
18
+ h2: Style.new(color: Color.parse("cyan"), bold: true),
19
+ h3: Style.new(color: Color.parse("bright_blue"), bold: true),
20
+ h4: Style.new(color: Color.parse("blue"), bold: true),
21
+ h5: Style.new(color: Color.parse("bright_magenta")),
22
+ h6: Style.new(color: Color.parse("magenta")),
23
+
24
+ # Text formatting
25
+ bold: Style.new(bold: true),
26
+ italic: Style.new(italic: true),
27
+ bold_italic: Style.new(bold: true, italic: true),
28
+ strikethrough: Style.new(strike: true),
29
+ code_inline: Style.new(color: Color.parse("bright_green"), bgcolor: Color.parse("color(236)")),
30
+
31
+ # Links and references
32
+ link: Style.new(color: Color.parse("bright_blue"), underline: true),
33
+ link_url: Style.new(color: Color.parse("blue"), dim: true),
34
+
35
+ # Lists
36
+ bullet: Style.new(color: Color.parse("yellow")),
37
+ list_number: Style.new(color: Color.parse("yellow")),
38
+
39
+ # Blockquotes
40
+ blockquote: Style.new(color: Color.parse("bright_black"), italic: true),
41
+ blockquote_border: Style.new(color: Color.parse("magenta")),
42
+
43
+ # Code blocks
44
+ code_block: Style.new(bgcolor: Color.parse("color(236)")),
45
+ code_border: Style.new(color: Color.parse("bright_black")),
46
+
47
+ # Horizontal rule
48
+ hr: Style.new(color: Color.parse("bright_black")),
49
+
50
+ # Table
51
+ table_header: Style.new(bold: true, color: Color.parse("cyan")),
52
+ table_border: Style.new(color: Color.parse("bright_black"))
53
+ }.freeze
54
+
55
+ # @return [String] Source markdown
56
+ attr_reader :source
57
+
58
+ # @return [Hash] Style configuration
59
+ attr_reader :styles
60
+
61
+ # @return [Boolean] Use hyperlinks
62
+ attr_reader :hyperlinks
63
+
64
+ # @return [Integer] Code block indent
65
+ attr_reader :code_indent
66
+
67
+ # Create a new Markdown renderer
68
+ # @param source [String] Markdown source text
69
+ # @param styles [Hash] Custom styles to override defaults
70
+ # @param hyperlinks [Boolean] Enable terminal hyperlinks
71
+ # @param code_indent [Integer] Indent for code blocks
72
+ def initialize(source, styles: {}, hyperlinks: true, code_indent: 4)
73
+ @source = source.to_s
74
+ @styles = DEFAULT_STYLES.merge(styles)
75
+ @hyperlinks = hyperlinks
76
+ @code_indent = code_indent
77
+ end
78
+
79
+ # Render markdown to string with ANSI codes
80
+ # @param max_width [Integer] Maximum width
81
+ # @return [String]
82
+ def render(max_width: 80)
83
+ lines = parse_and_render(max_width: max_width)
84
+ lines.join("\n")
85
+ end
86
+
87
+ # Convert to segments
88
+ # @param max_width [Integer] Maximum width
89
+ # @return [Array<Segment>]
90
+ def to_segments(max_width: 80)
91
+ segments = []
92
+ lines = parse_and_render(max_width: max_width)
93
+
94
+ lines.each_with_index do |line, i|
95
+ # Line is already a rendered string with ANSI codes
96
+ segments << Segment.new(line)
97
+ segments << Segment.new("\n") if i < lines.length - 1
98
+ end
99
+
100
+ segments
101
+ end
102
+
103
+ class << self
104
+ # Render markdown from string
105
+ # @param source [String] Markdown text
106
+ # @param kwargs [Hash] Options
107
+ # @return [String]
108
+ def render(source, **kwargs)
109
+ new(source, **kwargs).render(**kwargs)
110
+ end
111
+
112
+ # Render markdown from file
113
+ # @param path [String] File path
114
+ # @param kwargs [Hash] Options
115
+ # @return [String]
116
+ def from_file(path, **kwargs)
117
+ source = File.read(path)
118
+ new(source, **kwargs)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Parse and render markdown
125
+ # @param max_width [Integer] Maximum width
126
+ # @return [Array<String>]
127
+ def parse_and_render(max_width:)
128
+ lines = @source.lines.map(&:chomp)
129
+ output = []
130
+ i = 0
131
+
132
+ while i < lines.length
133
+ line = lines[i]
134
+
135
+ # Blank line
136
+ if line.strip.empty?
137
+ output << ""
138
+ i += 1
139
+ next
140
+ end
141
+
142
+ # Fenced code block
143
+ if line.match?(/^```/)
144
+ lang = line[3..].strip
145
+ code_lines = []
146
+ i += 1
147
+ while i < lines.length && !lines[i].start_with?("```")
148
+ code_lines << lines[i]
149
+ i += 1
150
+ end
151
+ output.concat(render_code_block(code_lines.join("\n"), lang, max_width))
152
+ i += 1
153
+ next
154
+ end
155
+
156
+ # Heading
157
+ if line.match?(%r{^\#{1,6}\s})
158
+ output.concat(render_heading(line, max_width))
159
+ i += 1
160
+ next
161
+ end
162
+
163
+ # Horizontal rule
164
+ if line.match?(/^[-*_]{3,}\s*$/)
165
+ output << render_hr(max_width)
166
+ i += 1
167
+ next
168
+ end
169
+
170
+ # Unordered list
171
+ if line.match?(/^\s*[-*+]\s/)
172
+ list_lines = [line]
173
+ i += 1
174
+ while i < lines.length && (lines[i].match?(/^\s*[-*+]\s/) || lines[i].match?(/^\s{2,}/))
175
+ list_lines << lines[i]
176
+ i += 1
177
+ end
178
+ output.concat(render_unordered_list(list_lines, max_width))
179
+ next
180
+ end
181
+
182
+ # Ordered list
183
+ if line.match?(/^\s*\d+\.\s/)
184
+ list_lines = [line]
185
+ i += 1
186
+ while i < lines.length && (lines[i].match?(/^\s*\d+\.\s/) || lines[i].match?(/^\s{2,}/))
187
+ list_lines << lines[i]
188
+ i += 1
189
+ end
190
+ output.concat(render_ordered_list(list_lines, max_width))
191
+ next
192
+ end
193
+
194
+ # Blockquote
195
+ if line.start_with?(">")
196
+ quote_lines = [line]
197
+ i += 1
198
+ while i < lines.length && (lines[i].start_with?(">") || (!lines[i].strip.empty? && !lines[i].match?(/^[#\-*+\d]/)))
199
+ quote_lines << lines[i]
200
+ i += 1
201
+ end
202
+ output.concat(render_blockquote(quote_lines, max_width))
203
+ next
204
+ end
205
+
206
+ # Table
207
+ if line.include?("|") && i + 1 < lines.length && lines[i + 1].match?(/^\|?\s*[-:]+/)
208
+ table_lines = [line]
209
+ i += 1
210
+ while i < lines.length && lines[i].include?("|")
211
+ table_lines << lines[i]
212
+ i += 1
213
+ end
214
+ output.concat(render_table(table_lines, max_width))
215
+ next
216
+ end
217
+
218
+ # Regular paragraph
219
+ para_lines = [line]
220
+ i += 1
221
+ while i < lines.length && !lines[i].strip.empty? && !lines[i].match?(/^[\#\-*+>\d`|]/)
222
+ para_lines << lines[i]
223
+ i += 1
224
+ end
225
+ output.concat(render_paragraph(para_lines.join(" "), max_width))
226
+ end
227
+
228
+ output
229
+ end
230
+
231
+ # Render a heading
232
+ def render_heading(line, max_width)
233
+ match = line.match(%r{^(\#{1,6})\s+(.*)})
234
+ return [line] unless match
235
+
236
+ level = match[1].length
237
+ text = match[2]
238
+
239
+ style = @styles[:"h#{level}"] || @styles[:h1]
240
+ styled_text = apply_inline_styles(text)
241
+
242
+ result = []
243
+
244
+ # Add decorations based on level
245
+ case level
246
+ when 1
247
+ border = style.render + ("=" * [text.length + 4, max_width].min) + "\e[0m"
248
+ result << border
249
+ result << style.render + " #{styled_text} " + "\e[0m"
250
+ result << border
251
+ when 2
252
+ result << style.render + styled_text + "\e[0m"
253
+ result << style.render + ("-" * [text.length, max_width].min) + "\e[0m"
254
+ else
255
+ prefix = "#" * level + " "
256
+ result << style.render + prefix + styled_text + "\e[0m"
257
+ end
258
+
259
+ result << ""
260
+ result
261
+ end
262
+
263
+ # Render horizontal rule
264
+ def render_hr(max_width)
265
+ @styles[:hr].render + ("─" * max_width) + "\e[0m"
266
+ end
267
+
268
+ # Render unordered list
269
+ def render_unordered_list(lines, max_width)
270
+ result = []
271
+ indent = 0
272
+
273
+ lines.each do |line|
274
+ match = line.match(/^(\s*)([-*+])\s+(.*)/)
275
+ next unless match
276
+
277
+ spaces = match[1]
278
+ content = match[3]
279
+
280
+ # Calculate indent level
281
+ indent = spaces.length / 2
282
+
283
+ bullet_char = case indent
284
+ when 0 then "•"
285
+ when 1 then "◦"
286
+ else "▪"
287
+ end
288
+
289
+ prefix = " " * indent
290
+ bullet = @styles[:bullet].render + bullet_char + "\e[0m "
291
+ styled_content = apply_inline_styles(content)
292
+
293
+ result << prefix + bullet + styled_content
294
+ end
295
+
296
+ result << ""
297
+ result
298
+ end
299
+
300
+ # Render ordered list
301
+ def render_ordered_list(lines, max_width)
302
+ result = []
303
+ counter = 0
304
+
305
+ lines.each do |line|
306
+ match = line.match(/^(\s*)(\d+)\.\s+(.*)/)
307
+ next unless match
308
+
309
+ spaces = match[1]
310
+ counter += 1
311
+ content = match[3]
312
+
313
+ indent = spaces.length / 2
314
+ prefix = " " * indent
315
+ num = @styles[:list_number].render + "#{counter}." + "\e[0m "
316
+ styled_content = apply_inline_styles(content)
317
+
318
+ result << prefix + num + styled_content
319
+ end
320
+
321
+ result << ""
322
+ result
323
+ end
324
+
325
+ # Render blockquote
326
+ def render_blockquote(lines, max_width)
327
+ result = []
328
+ border_style = @styles[:blockquote_border]
329
+ text_style = @styles[:blockquote]
330
+
331
+ lines.each do |line|
332
+ content = line.sub(/^>\s*/, "")
333
+ styled = text_style.render + apply_inline_styles(content) + "\e[0m"
334
+ result << border_style.render + "│ " + "\e[0m" + styled
335
+ end
336
+
337
+ result << ""
338
+ result
339
+ end
340
+
341
+ # Render code block
342
+ def render_code_block(code, language, max_width)
343
+ result = []
344
+ indent = " " * @code_indent
345
+ style = @styles[:code_block]
346
+
347
+ # Header with language
348
+ if language && !language.empty?
349
+ lang_display = " #{language} "
350
+ result << @styles[:code_border].render + "┌" + ("─" * (max_width - 2)) + "┐" + "\e[0m"
351
+ result << @styles[:code_border].render + "│" + "\e[0m" + " " + lang_display.ljust(max_width - 4) + @styles[:code_border].render + " │" + "\e[0m"
352
+ result << @styles[:code_border].render + "├" + ("─" * (max_width - 2)) + "┤" + "\e[0m"
353
+ else
354
+ result << @styles[:code_border].render + "┌" + ("─" * (max_width - 2)) + "┐" + "\e[0m"
355
+ end
356
+
357
+ # Code lines
358
+ code.each_line do |line|
359
+ line = line.chomp
360
+ padded = line.ljust(max_width - 4)
361
+ result << @styles[:code_border].render + "│ " + "\e[0m" + style.render + padded + "\e[0m" + @styles[:code_border].render + " │" + "\e[0m"
362
+ end
363
+
364
+ # Footer
365
+ result << @styles[:code_border].render + "└" + ("─" * (max_width - 2)) + "┘" + "\e[0m"
366
+ result << ""
367
+
368
+ result
369
+ end
370
+
371
+ # Render table
372
+ def render_table(lines, max_width)
373
+ return [] if lines.empty?
374
+
375
+ # Parse table
376
+ rows = lines.map do |line|
377
+ line.split("|").map(&:strip).reject(&:empty?)
378
+ end
379
+
380
+ return [] if rows.empty?
381
+
382
+ # Skip separator row
383
+ rows.reject! { |row| row.all? { |cell| cell.match?(/^[-:]+$/) } }
384
+ return [] if rows.empty?
385
+
386
+ header = rows.first
387
+ body = rows[1..]
388
+
389
+ # Calculate column widths
390
+ col_widths = header.map(&:length)
391
+ body&.each do |row|
392
+ row.each_with_index do |cell, i|
393
+ col_widths[i] = [col_widths[i] || 0, cell.length].max
394
+ end
395
+ end
396
+
397
+ result = []
398
+ border_style = @styles[:table_border]
399
+ header_style = @styles[:table_header]
400
+
401
+ # Top border
402
+ top = col_widths.map { |w| "─" * (w + 2) }.join("┬")
403
+ result << border_style.render + "┌" + top + "┐" + "\e[0m"
404
+
405
+ # Header row
406
+ header_cells = header.each_with_index.map do |cell, i|
407
+ " " + header_style.render + cell.ljust(col_widths[i]) + "\e[0m" + " "
408
+ end
409
+ result << border_style.render + "│" + "\e[0m" + header_cells.join(border_style.render + "│" + "\e[0m") + border_style.render + "│" + "\e[0m"
410
+
411
+ # Header separator
412
+ sep = col_widths.map { |w| "━" * (w + 2) }.join("┿")
413
+ result << border_style.render + "┝" + sep + "┥" + "\e[0m"
414
+
415
+ # Body rows
416
+ body&.each do |row|
417
+ cells = row.each_with_index.map do |cell, i|
418
+ width = col_widths[i] || cell.length
419
+ " " + apply_inline_styles(cell).ljust(width) + " "
420
+ end
421
+ # Pad missing cells
422
+ while cells.length < col_widths.length
423
+ cells << " " * (col_widths[cells.length] + 2)
424
+ end
425
+ result << border_style.render + "│" + "\e[0m" + cells.join(border_style.render + "│" + "\e[0m") + border_style.render + "│" + "\e[0m"
426
+ end
427
+
428
+ # Bottom border
429
+ bottom = col_widths.map { |w| "─" * (w + 2) }.join("┴")
430
+ result << border_style.render + "└" + bottom + "┘" + "\e[0m"
431
+
432
+ result << ""
433
+ result
434
+ end
435
+
436
+ # Render paragraph
437
+ def render_paragraph(text, max_width)
438
+ styled = apply_inline_styles(text)
439
+
440
+ # Word wrap
441
+ words = styled.split(/(\s+)/)
442
+ lines = []
443
+ current_line = ""
444
+ current_width = 0
445
+
446
+ words.each do |word|
447
+ word_width = Cells.cell_len(Control.strip_ansi(word))
448
+
449
+ if current_width + word_width > max_width && !current_line.empty?
450
+ lines << current_line.rstrip
451
+ current_line = ""
452
+ current_width = 0
453
+ end
454
+
455
+ current_line += word
456
+ current_width += word_width
457
+ end
458
+
459
+ lines << current_line.rstrip unless current_line.empty?
460
+ lines << ""
461
+
462
+ lines
463
+ end
464
+
465
+ # Apply inline styles (bold, italic, code, links)
466
+ def apply_inline_styles(text)
467
+ result = text.dup
468
+
469
+ # Bold italic (***text*** or ___text___)
470
+ result.gsub!(/(\*\*\*|___)([^*_]+)\1/) do
471
+ @styles[:bold_italic].render + ::Regexp.last_match(2) + "\e[0m"
472
+ end
473
+
474
+ # Bold (**text** or __text__)
475
+ result.gsub!(/(\*\*|__)([^*_]+)\1/) do
476
+ @styles[:bold].render + ::Regexp.last_match(2) + "\e[0m"
477
+ end
478
+
479
+ # Italic (*text* or _text_)
480
+ result.gsub!(/(\*|_)([^*_]+)\1/) do
481
+ @styles[:italic].render + ::Regexp.last_match(2) + "\e[0m"
482
+ end
483
+
484
+ # Strikethrough (~~text~~)
485
+ result.gsub!(/~~([^~]+)~~/) do
486
+ @styles[:strikethrough].render + ::Regexp.last_match(1) + "\e[0m"
487
+ end
488
+
489
+ # Inline code (`code`)
490
+ result.gsub!(/`([^`]+)`/) do
491
+ @styles[:code_inline].render + ::Regexp.last_match(1) + "\e[0m"
492
+ end
493
+
494
+ # Links [text](url)
495
+ result.gsub!(/\[([^\]]+)\]\(([^)]+)\)/) do
496
+ text_part = ::Regexp.last_match(1)
497
+ url = ::Regexp.last_match(2)
498
+
499
+ if @hyperlinks
500
+ @styles[:link].render + Control.hyperlink(url, text_part) + "\e[0m"
501
+ else
502
+ @styles[:link].render + text_part + "\e[0m" + " (" + @styles[:link_url].render + url + "\e[0m" + ")"
503
+ end
504
+ end
505
+
506
+ result
507
+ end
508
+ end
509
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+ require_relative "text"
5
+
6
+ module Rich
7
+ # Markup parsing error
8
+ class MarkupError < StandardError
9
+ end
10
+
11
+ # Parser for Rich markup syntax: [style]text[/style]
12
+ module Markup
13
+ # Tag regex for matching markup tags, excluding escaped ones
14
+ TAG_REGEX = /(?<!\\)\[(?<closing>\/)?(?<tag>[^\[\]\/]*)\]/
15
+
16
+ class << self
17
+ # Parse markup into a Text object
18
+ # @param markup [String] Markup text
19
+ # @param style [Style, String, nil] Base style
20
+ # @return [Text]
21
+ def parse(markup, style: nil)
22
+ result_text = Text.new(style: style)
23
+ style_stack = []
24
+ pos = 0
25
+
26
+ markup.scan(TAG_REGEX) do
27
+ match = Regexp.last_match
28
+ tag_start = match.begin(0)
29
+
30
+ # Add text before tag
31
+ if tag_start > pos
32
+ pre_text = unescape(markup[pos...tag_start])
33
+ start_pos = result_text.length
34
+ result_text.append(pre_text)
35
+
36
+ # Apply stacked styles to this text
37
+ style_stack.each do |stacked_style|
38
+ result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
39
+ end
40
+ end
41
+
42
+ # Process tag
43
+ if match[:closing]
44
+ # Closing tag - pop style
45
+ style_stack.pop unless style_stack.empty?
46
+ else
47
+ # Opening tag - parse and push style
48
+ tag_content = match[:tag].strip
49
+ if tag_content.empty?
50
+ # Literal []
51
+ result_text.append("[]")
52
+ else
53
+ begin
54
+ parsed_style = Style.parse(tag_content)
55
+ style_stack << parsed_style
56
+ rescue StandardError
57
+ # Invalid style, treat as literal text
58
+ result_text.append("[#{tag_content}]")
59
+ end
60
+ end
61
+ end
62
+
63
+ pos = match.end(0)
64
+ end
65
+
66
+ # Add remaining text
67
+ if pos < markup.length
68
+ remaining = unescape(markup[pos..])
69
+ start_pos = result_text.length
70
+ result_text.append(remaining)
71
+
72
+ style_stack.each do |stacked_style|
73
+ result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
74
+ end
75
+ end
76
+
77
+ result_text
78
+ end
79
+
80
+ # Render markup directly to ANSI string
81
+ # @param markup [String] Markup text
82
+ # @param color_system [Symbol] Color system
83
+ # @return [String]
84
+ def render(markup, color_system: ColorSystem::TRUECOLOR)
85
+ parse(markup).render(color_system: color_system)
86
+ end
87
+
88
+ # Escape text for use in markup (escape square brackets)
89
+ # @param text [String] Text to escape
90
+ # @return [String]
91
+ def escape(text)
92
+ text.gsub(/[\[\]]/) { |m| "\\#{m}" }
93
+ end
94
+
95
+ # Unescape markup text
96
+ # @param text [String] Text to unescape
97
+ # @return [String]
98
+ def unescape(text)
99
+ text.gsub(/\\([\[\]\\])/, '\1')
100
+ end
101
+
102
+ # Strip markup tags from text
103
+ # @param markup [String] Markup text
104
+ # @return [String]
105
+ def strip(markup)
106
+ markup.gsub(TAG_REGEX, "")
107
+ end
108
+
109
+ # Check if text contains markup
110
+ # @param text [String] Text to check
111
+ # @return [Boolean]
112
+ def contains_markup?(text)
113
+ text.match?(TAG_REGEX)
114
+ end
115
+
116
+ # Extract all tags from markup
117
+ # @param markup [String] Markup text
118
+ # @return [Array<Hash>] Array of tag info
119
+ def extract_tags(markup)
120
+ tags = []
121
+
122
+ markup.scan(TAG_REGEX) do
123
+ match = Regexp.last_match
124
+ tags << {
125
+ position: match.begin(0),
126
+ closing: !match[:closing].nil?,
127
+ tag: match[:tag].to_s.strip,
128
+ full_match: match[0]
129
+ }
130
+ end
131
+
132
+ tags
133
+ end
134
+
135
+ # Validate markup (check for unclosed tags)
136
+ # @param markup [String] Markup to validate
137
+ # @return [Array<String>] List of errors (empty if valid)
138
+ def validate(markup)
139
+ errors = []
140
+ open_tags = []
141
+
142
+ extract_tags(markup).each do |tag|
143
+ if tag[:closing]
144
+ if open_tags.empty?
145
+ errors << "Unexpected closing tag [/#{tag[:tag]}] at position #{tag[:position]}"
146
+ else
147
+ # In Rich, [/] closes the LAST tag, [ /tag] closes specific tag
148
+ # Let's keep it simple for now: pop last.
149
+ # If tag name matches, pop it. If it doesn't match and not empty, it's an error.
150
+ last_tag = open_tags.pop
151
+ if !tag[:tag].empty? && tag[:tag] != last_tag
152
+ errors << "Mismatched closing tag [/#{tag[:tag]}] for [#{last_tag}]"
153
+ end
154
+ end
155
+ else
156
+ open_tags << tag[:tag]
157
+ end
158
+ end
159
+
160
+ open_tags.each do |tag|
161
+ errors << "Unclosed tag [#{tag}]"
162
+ end
163
+
164
+ errors
165
+ end
166
+
167
+ # Check if markup is valid
168
+ # @param markup [String] Markup to check
169
+ # @return [Boolean]
170
+ def valid?(markup)
171
+ validate(markup).empty?
172
+ end
173
+ end
174
+ end
175
+ end