Module: Rich::Markup

Defined in:
lib/rich/markup.rb

Overview

Parser for Rich markup syntax: [style]text

Constant Summary collapse

TAG_REGEX =

Tag regex for matching markup tags, excluding escaped ones

/(?<!\\)\[(?<closing>\/)?(?<tag>[^\[\]\/]*)\]/

Class Method Summary collapse

Class Method Details

.contains_markup?(text) ⇒ Boolean

Check if text contains markup

Parameters:

  • text (String)

    Text to check

Returns:

  • (Boolean)


123
124
125
# File 'lib/rich/markup.rb', line 123

def contains_markup?(text)
  text.match?(TAG_REGEX)
end

.escape(text) ⇒ String

Escape text for use in markup (escape square brackets)

Parameters:

  • text (String)

    Text to escape

Returns:

  • (String)


102
103
104
# File 'lib/rich/markup.rb', line 102

def escape(text)
  text.gsub(/[\[\]]/) { |m| "\\#{m}" }
end

.extract_tags(markup) ⇒ Array<Hash>

Extract all tags from markup

Parameters:

  • markup (String)

    Markup text

Returns:

  • (Array<Hash>)

    Array of tag info



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/rich/markup.rb', line 130

def extract_tags(markup)
  tags = []

  markup.scan(TAG_REGEX) do
    match = Regexp.last_match
    tags << {
      position: match.begin(0),
      closing: !match[:closing].nil?,
      tag: match[:tag].to_s.strip,
      full_match: match[0]
    }
  end

  tags
end

.parse(markup, style: nil) ⇒ Text

Parse markup into a Text object

Parameters:

  • markup (String)

    Markup text

  • style (Style, String, nil) (defaults to: nil)

    Base style

Returns:



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/rich/markup.rb', line 21

def parse(markup, style: nil)
  result_text = Text.new(style: style)
  # Each stack entry is [tag_name, style] so closing tags can match by
  # name (matching validate's semantics and Python Rich).
  style_stack = []
  pos = 0

  markup.scan(TAG_REGEX) do
    match = Regexp.last_match
    tag_start = match.begin(0)

    # Add text before tag
    if tag_start > pos
      pre_text = unescape(markup[pos...tag_start])
      start_pos = result_text.length
      result_text.append(pre_text)

      # Apply stacked styles to this text
      style_stack.each do |(_name, stacked_style)|
        result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
      end
    end

    # Process tag
    if match[:closing]
      # Closing tag - close the matching open tag by name; a bare [/]
      # closes the most recently opened tag.
      tag_name = match[:tag].strip
      unless style_stack.empty?
        if tag_name.empty?
          style_stack.pop
        else
          idx = style_stack.rindex { |(name, _)| name == tag_name }
          idx ? style_stack.delete_at(idx) : style_stack.pop
        end
      end
    else
      # Opening tag - parse and push style
      tag_content = match[:tag].strip
      if tag_content.empty?
        # Literal []
        result_text.append("[]")
      else
        begin
          parsed_style = Style.parse(tag_content)
          style_stack << [tag_content, parsed_style]
        rescue StandardError
          # Invalid style, treat as literal text
          result_text.append("[#{tag_content}]")
        end
      end
    end

    pos = match.end(0)
  end

  # Add remaining text
  if pos < markup.length
    remaining = unescape(markup[pos..])
    start_pos = result_text.length
    result_text.append(remaining)

    style_stack.each do |(_name, stacked_style)|
      result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
    end
  end

  result_text
end

.render(markup, color_system: ColorSystem::TRUECOLOR) ⇒ String

Render markup directly to ANSI string

Parameters:

  • markup (String)

    Markup text

  • color_system (Symbol) (defaults to: ColorSystem::TRUECOLOR)

    Color system

Returns:

  • (String)


95
96
97
# File 'lib/rich/markup.rb', line 95

def render(markup, color_system: ColorSystem::TRUECOLOR)
  parse(markup).render(color_system: color_system)
end

.strip(markup) ⇒ String

Strip markup tags from text

Parameters:

  • markup (String)

    Markup text

Returns:

  • (String)


116
117
118
# File 'lib/rich/markup.rb', line 116

def strip(markup)
  markup.gsub(TAG_REGEX, "")
end

.unescape(text) ⇒ String

Unescape markup text

Parameters:

  • text (String)

    Text to unescape

Returns:

  • (String)


109
110
111
# File 'lib/rich/markup.rb', line 109

def unescape(text)
  text.gsub(/\\([\[\]\\])/, '\1')
end

.valid?(markup) ⇒ Boolean

Check if markup is valid

Parameters:

  • markup (String)

    Markup to check

Returns:

  • (Boolean)


181
182
183
# File 'lib/rich/markup.rb', line 181

def valid?(markup)
  validate(markup).empty?
end

.validate(markup) ⇒ Array<String>

Validate markup (check for unclosed tags)

Parameters:

  • markup (String)

    Markup to validate

Returns:

  • (Array<String>)

    List of errors (empty if valid)



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/rich/markup.rb', line 149

def validate(markup)
  errors = []
  open_tags = []

  extract_tags(markup).each do |tag|
    if tag[:closing]
      if open_tags.empty?
        errors << "Unexpected closing tag [/#{tag[:tag]}] at position #{tag[:position]}"
      else
        # In Rich, [/] closes the LAST tag, [ /tag] closes specific tag
        # Let's keep it simple for now: pop last.
        # If tag name matches, pop it. If it doesn't match and not empty, it's an error.
        last_tag = open_tags.pop
        if !tag[:tag].empty? && tag[:tag] != last_tag
          errors << "Mismatched closing tag [/#{tag[:tag]}] for [#{last_tag}]"
        end
      end
    else
      open_tags << tag[:tag]
    end
  end

  open_tags.each do |tag|
    errors << "Unclosed tag [#{tag}]"
  end

  errors
end