rich-ruby 1.0.1 → 1.0.2

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.
data/lib/rich/markup.rb CHANGED
@@ -1,175 +1,186 @@
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
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
+ # Each stack entry is [tag_name, style] so closing tags can match by
24
+ # name (matching validate's semantics and Python Rich).
25
+ style_stack = []
26
+ pos = 0
27
+
28
+ markup.scan(TAG_REGEX) do
29
+ match = Regexp.last_match
30
+ tag_start = match.begin(0)
31
+
32
+ # Add text before tag
33
+ if tag_start > pos
34
+ pre_text = unescape(markup[pos...tag_start])
35
+ start_pos = result_text.length
36
+ result_text.append(pre_text)
37
+
38
+ # Apply stacked styles to this text
39
+ style_stack.each do |(_name, stacked_style)|
40
+ result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
41
+ end
42
+ end
43
+
44
+ # Process tag
45
+ if match[:closing]
46
+ # Closing tag - close the matching open tag by name; a bare [/]
47
+ # closes the most recently opened tag.
48
+ tag_name = match[:tag].strip
49
+ unless style_stack.empty?
50
+ if tag_name.empty?
51
+ style_stack.pop
52
+ else
53
+ idx = style_stack.rindex { |(name, _)| name == tag_name }
54
+ idx ? style_stack.delete_at(idx) : style_stack.pop
55
+ end
56
+ end
57
+ else
58
+ # Opening tag - parse and push style
59
+ tag_content = match[:tag].strip
60
+ if tag_content.empty?
61
+ # Literal []
62
+ result_text.append("[]")
63
+ else
64
+ begin
65
+ parsed_style = Style.parse(tag_content)
66
+ style_stack << [tag_content, parsed_style]
67
+ rescue StandardError
68
+ # Invalid style, treat as literal text
69
+ result_text.append("[#{tag_content}]")
70
+ end
71
+ end
72
+ end
73
+
74
+ pos = match.end(0)
75
+ end
76
+
77
+ # Add remaining text
78
+ if pos < markup.length
79
+ remaining = unescape(markup[pos..])
80
+ start_pos = result_text.length
81
+ result_text.append(remaining)
82
+
83
+ style_stack.each do |(_name, stacked_style)|
84
+ result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
85
+ end
86
+ end
87
+
88
+ result_text
89
+ end
90
+
91
+ # Render markup directly to ANSI string
92
+ # @param markup [String] Markup text
93
+ # @param color_system [Symbol] Color system
94
+ # @return [String]
95
+ def render(markup, color_system: ColorSystem::TRUECOLOR)
96
+ parse(markup).render(color_system: color_system)
97
+ end
98
+
99
+ # Escape text for use in markup (escape square brackets)
100
+ # @param text [String] Text to escape
101
+ # @return [String]
102
+ def escape(text)
103
+ text.gsub(/[\[\]]/) { |m| "\\#{m}" }
104
+ end
105
+
106
+ # Unescape markup text
107
+ # @param text [String] Text to unescape
108
+ # @return [String]
109
+ def unescape(text)
110
+ text.gsub(/\\([\[\]\\])/, '\1')
111
+ end
112
+
113
+ # Strip markup tags from text
114
+ # @param markup [String] Markup text
115
+ # @return [String]
116
+ def strip(markup)
117
+ markup.gsub(TAG_REGEX, "")
118
+ end
119
+
120
+ # Check if text contains markup
121
+ # @param text [String] Text to check
122
+ # @return [Boolean]
123
+ def contains_markup?(text)
124
+ text.match?(TAG_REGEX)
125
+ end
126
+
127
+ # Extract all tags from markup
128
+ # @param markup [String] Markup text
129
+ # @return [Array<Hash>] Array of tag info
130
+ def extract_tags(markup)
131
+ tags = []
132
+
133
+ markup.scan(TAG_REGEX) do
134
+ match = Regexp.last_match
135
+ tags << {
136
+ position: match.begin(0),
137
+ closing: !match[:closing].nil?,
138
+ tag: match[:tag].to_s.strip,
139
+ full_match: match[0]
140
+ }
141
+ end
142
+
143
+ tags
144
+ end
145
+
146
+ # Validate markup (check for unclosed tags)
147
+ # @param markup [String] Markup to validate
148
+ # @return [Array<String>] List of errors (empty if valid)
149
+ def validate(markup)
150
+ errors = []
151
+ open_tags = []
152
+
153
+ extract_tags(markup).each do |tag|
154
+ if tag[:closing]
155
+ if open_tags.empty?
156
+ errors << "Unexpected closing tag [/#{tag[:tag]}] at position #{tag[:position]}"
157
+ else
158
+ # In Rich, [/] closes the LAST tag, [ /tag] closes specific tag
159
+ # Let's keep it simple for now: pop last.
160
+ # If tag name matches, pop it. If it doesn't match and not empty, it's an error.
161
+ last_tag = open_tags.pop
162
+ if !tag[:tag].empty? && tag[:tag] != last_tag
163
+ errors << "Mismatched closing tag [/#{tag[:tag]}] for [#{last_tag}]"
164
+ end
165
+ end
166
+ else
167
+ open_tags << tag[:tag]
168
+ end
169
+ end
170
+
171
+ open_tags.each do |tag|
172
+ errors << "Unclosed tag [#{tag}]"
173
+ end
174
+
175
+ errors
176
+ end
177
+
178
+ # Check if markup is valid
179
+ # @param markup [String] Markup to check
180
+ # @return [Boolean]
181
+ def valid?(markup)
182
+ validate(markup).empty?
183
+ end
184
+ end
185
+ end
186
+ end