cli-ui 1.5.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8de8c1fd6c855f812125e71e85dbfc12163dfd504215fab462d56ad5f4d2cc7e
4
- data.tar.gz: 404f164084fd9cf85aa382a5d5f93b77b08ea4bc529840ebfe230e1b5ea7c620
3
+ metadata.gz: d8dec5198cdbbb3a6060fc448a9a03b5bc6eca461b94a62ead0f128664bb0ee2
4
+ data.tar.gz: bed02e7e27771141c08859c11d7e01f690438bb46d4022f5225d8cb42e39efe4
5
5
  SHA512:
6
- metadata.gz: 9f737c534e372589165a3263854d3a90f024181f632f6ae918fcb7aafea69abad27f50246d35c2957c238447a74871f4705add26d1bf272ad58f4fcc52d31ae6
7
- data.tar.gz: 56c449900c817637b97d0e99b05ac01375bbde1458db3f2d5e7122027cdd525e0b8aaf9b9d5290cc2f1fa8cae314efb1365ac46ab277629f5cebf5d113e39b0d
6
+ metadata.gz: 6944f7c1b8492e69bd078b2ca0d9901d26694e4542976cb07729efff2cb59438cbc2ec61692b75ccf178ffcec896a297264be5734520695f9bc11779f2698ead
7
+ data.tar.gz: c9795b1749d3db7cb57bccca815495ed9b2e8ee02092fe95b46bf451f2993e65c33f8b48e9f6520b2984b7278db64cb9e92bb1fe58a761691a9b313dab6ef464
data/README.md CHANGED
@@ -3,7 +3,7 @@ CLI UI
3
3
 
4
4
  CLI UI is a small framework for generating nice command-line user interfaces
5
5
 
6
- - [Master Documentation](http://www.rubydoc.info/github/Shopify/cli-ui/master/CLI/UI)
6
+ - [Master Documentation](http://www.rubydoc.info/github/Shopify/cli-ui/main/CLI/UI)
7
7
  - [Documentation of the Rubygems version](http://www.rubydoc.info/gems/cli-ui/)
8
8
  - [Rubygems](https://rubygems.org/gems/cli-ui)
9
9
 
@@ -23,7 +23,7 @@ In your code, simply add a `require 'cli/ui'`. Most options assume `CLI::UI::Std
23
23
 
24
24
  ## Features
25
25
 
26
- This may not be an exhaustive list. Please check our [documentation](http://www.rubydoc.info/github/Shopify/cli-ui/master/CLI/UI) for more information.
26
+ This may not be an exhaustive list. Please check our [documentation](http://www.rubydoc.info/github/Shopify/cli-ui/main/CLI/UI) for more information.
27
27
 
28
28
  ---
29
29
 
@@ -83,10 +83,10 @@ CLI::UI.ask('Is CLI UI Awesome?', default: 'It is great!')
83
83
  Handle many multi-threaded processes while suppressing output unless there is an issue. Can update title to show state.
84
84
 
85
85
  ```ruby
86
- spin_group = CLI::UI::SpinGroup.new
87
- spin_group.add('Title') { |spinner| sleep 3.0 }
88
- spin_group.add('Title 2') { |spinner| sleep 3.0; spinner.update_title('New Title'); sleep 3.0 }
89
- spin_group.wait
86
+ CLI::UI::SpinGroup.new do |spin_group|
87
+ spin_group.add('Title') { |spinner| sleep 3.0 }
88
+ spin_group.add('Title 2') { |spinner| sleep 3.0; spinner.update_title('New Title'); sleep 3.0 }
89
+ end
90
90
  ```
91
91
 
92
92
  ![Spinner Group](https://user-images.githubusercontent.com/3074765/33798295-d94fd822-dce3-11e7-819b-43e5502d490e.gif)
@@ -187,22 +187,22 @@ CLI::UI::StdoutRouter.enable
187
187
  CLI::UI::Frame.open('{{*}} {{bold:a}}', color: :green) do
188
188
  CLI::UI::Frame.open('{{i}} b', color: :magenta) do
189
189
  CLI::UI::Frame.open('{{?}} c', color: :cyan) do
190
- sg = CLI::UI::SpinGroup.new
191
- sg.add('wow') do |spinner|
192
- sleep(2.5)
193
- spinner.update_title('second round!')
194
- sleep (1.0)
190
+ CLI::UI::SpinGroup.new do |sg|
191
+ sg.add('wow') do |spinner|
192
+ sleep(2.5)
193
+ spinner.update_title('second round!')
194
+ sleep (1.0)
195
+ end
196
+ sg.add('such spin') { sleep(1.6) }
197
+ sg.add('many glyph') { sleep(2.0) }
195
198
  end
196
- sg.add('such spin') { sleep(1.6) }
197
- sg.add('many glyph') { sleep(2.0) }
198
- sg.wait
199
199
  end
200
200
  end
201
201
  CLI::UI::Frame.divider('{{v}} lol')
202
202
  puts CLI::UI.fmt '{{info:words}} {{red:oh no!}} {{green:success!}}'
203
- sg = CLI::UI::SpinGroup.new
204
- sg.add('more spins') { sleep(0.5) ; raise 'oh no' }
205
- sg.wait
203
+ CLI::UI::SpinGroup.new do |sg|
204
+ sg.add('more spins') { sleep(0.5) ; raise 'oh no' }
205
+ end
206
206
  end
207
207
  ```
208
208
 
data/lib/cli/ui/ansi.rb CHANGED
@@ -1,156 +1,184 @@
1
+ # typed: true
2
+
1
3
  require 'cli/ui'
2
4
 
3
5
  module CLI
4
6
  module UI
5
7
  module ANSI
8
+ extend T::Sig
9
+
6
10
  ESC = "\x1b"
7
11
 
8
- # ANSI escape sequences (like \x1b[31m) have zero width.
9
- # when calculating the padding width, we must exclude them.
10
- # This also implements a basic version of utf8 character width calculation like
11
- # we could get for real from something like utf8proc.
12
- #
13
- def self.printing_width(str)
14
- zwj = false
15
- strip_codes(str).codepoints.reduce(0) do |acc, cp|
16
- if zwj
17
- zwj = false
18
- next acc
19
- end
20
- case cp
21
- when 0x200d # zero-width joiner
22
- zwj = true
23
- acc
24
- when "\n"
25
- acc
26
- else
27
- acc + 1
12
+ class << self
13
+ extend T::Sig
14
+
15
+ # ANSI escape sequences (like \x1b[31m) have zero width.
16
+ # when calculating the padding width, we must exclude them.
17
+ # This also implements a basic version of utf8 character width calculation like
18
+ # we could get for real from something like utf8proc.
19
+ #
20
+ sig { params(str: String).returns(Integer) }
21
+ def printing_width(str)
22
+ zwj = T.let(false, T::Boolean)
23
+ strip_codes(str).codepoints.reduce(0) do |acc, cp|
24
+ if zwj
25
+ zwj = false
26
+ next acc
27
+ end
28
+ case cp
29
+ when 0x200d # zero-width joiner
30
+ zwj = true
31
+ acc
32
+ when "\n"
33
+ acc
34
+ else
35
+ acc + 1
36
+ end
28
37
  end
29
38
  end
30
- end
31
39
 
32
- # Strips ANSI codes from a str
33
- #
34
- # ==== Attributes
35
- #
36
- # - +str+ - The string from which to strip codes
37
- #
38
- def self.strip_codes(str)
39
- str.gsub(/\x1b\[[\d;]+[A-z]|\r/, '')
40
- end
40
+ # Strips ANSI codes from a str
41
+ #
42
+ # ==== Attributes
43
+ #
44
+ # - +str+ - The string from which to strip codes
45
+ #
46
+ sig { params(str: String).returns(String) }
47
+ def strip_codes(str)
48
+ str.gsub(/\x1b\[[\d;]+[A-z]|\r/, '')
49
+ end
41
50
 
42
- # Returns an ANSI control sequence
43
- #
44
- # ==== Attributes
45
- #
46
- # - +args+ - Argument to pass to the ANSI control sequence
47
- # - +cmd+ - ANSI control sequence Command
48
- #
49
- def self.control(args, cmd)
50
- ESC + '[' + args + cmd
51
- end
51
+ # Returns an ANSI control sequence
52
+ #
53
+ # ==== Attributes
54
+ #
55
+ # - +args+ - Argument to pass to the ANSI control sequence
56
+ # - +cmd+ - ANSI control sequence Command
57
+ #
58
+ sig { params(args: String, cmd: String).returns(String) }
59
+ def control(args, cmd)
60
+ ESC + '[' + args + cmd
61
+ end
52
62
 
53
- # https://en.wikipedia.org/wiki/ANSI_escape_code#graphics
54
- def self.sgr(params)
55
- control(params.to_s, 'm')
56
- end
63
+ # https://en.wikipedia.org/wiki/ANSI_escape_code#graphics
64
+ sig { params(params: String).returns(String) }
65
+ def sgr(params)
66
+ control(params, 'm')
67
+ end
57
68
 
58
- # Cursor Movement
59
-
60
- # Move the cursor up n lines
61
- #
62
- # ==== Attributes
63
- #
64
- # * +n+ - number of lines by which to move the cursor up
65
- #
66
- def self.cursor_up(n = 1)
67
- return '' if n.zero?
68
- control(n.to_s, 'A')
69
- end
69
+ # Cursor Movement
70
70
 
71
- # Move the cursor down n lines
72
- #
73
- # ==== Attributes
74
- #
75
- # * +n+ - number of lines by which to move the cursor down
76
- #
77
- def self.cursor_down(n = 1)
78
- return '' if n.zero?
79
- control(n.to_s, 'B')
80
- end
71
+ # Move the cursor up n lines
72
+ #
73
+ # ==== Attributes
74
+ #
75
+ # * +n+ - number of lines by which to move the cursor up
76
+ #
77
+ sig { params(n: Integer).returns(String) }
78
+ def cursor_up(n = 1)
79
+ return '' if n.zero?
81
80
 
82
- # Move the cursor forward n columns
83
- #
84
- # ==== Attributes
85
- #
86
- # * +n+ - number of columns by which to move the cursor forward
87
- #
88
- def self.cursor_forward(n = 1)
89
- return '' if n.zero?
90
- control(n.to_s, 'C')
91
- end
81
+ control(n.to_s, 'A')
82
+ end
92
83
 
93
- # Move the cursor back n columns
94
- #
95
- # ==== Attributes
96
- #
97
- # * +n+ - number of columns by which to move the cursor back
98
- #
99
- def self.cursor_back(n = 1)
100
- return '' if n.zero?
101
- control(n.to_s, 'D')
102
- end
84
+ # Move the cursor down n lines
85
+ #
86
+ # ==== Attributes
87
+ #
88
+ # * +n+ - number of lines by which to move the cursor down
89
+ #
90
+ sig { params(n: Integer).returns(String) }
91
+ def cursor_down(n = 1)
92
+ return '' if n.zero?
93
+
94
+ control(n.to_s, 'B')
95
+ end
103
96
 
104
- # Move the cursor to a specific column
105
- #
106
- # ==== Attributes
107
- #
108
- # * +n+ - The column to move to
109
- #
110
- def self.cursor_horizontal_absolute(n = 1)
111
- cmd = control(n.to_s, 'G')
112
- cmd += control('1', 'D') if CLI::UI::OS.current.shift_cursor_on_line_reset?
113
- cmd
114
- end
97
+ # Move the cursor forward n columns
98
+ #
99
+ # ==== Attributes
100
+ #
101
+ # * +n+ - number of columns by which to move the cursor forward
102
+ #
103
+ sig { params(n: Integer).returns(String) }
104
+ def cursor_forward(n = 1)
105
+ return '' if n.zero?
106
+
107
+ control(n.to_s, 'C')
108
+ end
115
109
 
116
- # Show the cursor
117
- #
118
- def self.show_cursor
119
- control('', '?25h')
120
- end
110
+ # Move the cursor back n columns
111
+ #
112
+ # ==== Attributes
113
+ #
114
+ # * +n+ - number of columns by which to move the cursor back
115
+ #
116
+ sig { params(n: Integer).returns(String) }
117
+ def cursor_back(n = 1)
118
+ return '' if n.zero?
119
+
120
+ control(n.to_s, 'D')
121
+ end
121
122
 
122
- # Hide the cursor
123
- #
124
- def self.hide_cursor
125
- control('', '?25l')
126
- end
123
+ # Move the cursor to a specific column
124
+ #
125
+ # ==== Attributes
126
+ #
127
+ # * +n+ - The column to move to
128
+ #
129
+ sig { params(n: Integer).returns(String) }
130
+ def cursor_horizontal_absolute(n = 1)
131
+ cmd = control(n.to_s, 'G')
132
+ cmd += cursor_back if CLI::UI::OS.current.shift_cursor_back_on_horizontal_absolute?
133
+ cmd
134
+ end
127
135
 
128
- # Save the cursor position
129
- #
130
- def self.cursor_save
131
- control('', 's')
132
- end
136
+ # Show the cursor
137
+ #
138
+ sig { returns(String) }
139
+ def show_cursor
140
+ control('', '?25h')
141
+ end
133
142
 
134
- # Restore the saved cursor position
135
- #
136
- def self.cursor_restore
137
- control('', 'u')
138
- end
143
+ # Hide the cursor
144
+ #
145
+ sig { returns(String) }
146
+ def hide_cursor
147
+ control('', '?25l')
148
+ end
139
149
 
140
- # Move to the next line
141
- #
142
- def self.next_line
143
- cursor_down + cursor_horizontal_absolute
144
- end
150
+ # Save the cursor position
151
+ #
152
+ sig { returns(String) }
153
+ def cursor_save
154
+ control('', 's')
155
+ end
145
156
 
146
- # Move to the previous line
147
- #
148
- def self.previous_line
149
- cursor_up + cursor_horizontal_absolute
150
- end
157
+ # Restore the saved cursor position
158
+ #
159
+ sig { returns(String) }
160
+ def cursor_restore
161
+ control('', 'u')
162
+ end
163
+
164
+ # Move to the next line
165
+ #
166
+ sig { returns(String) }
167
+ def next_line
168
+ cursor_down + cursor_horizontal_absolute
169
+ end
170
+
171
+ # Move to the previous line
172
+ #
173
+ sig { returns(String) }
174
+ def previous_line
175
+ cursor_up + cursor_horizontal_absolute
176
+ end
151
177
 
152
- def self.clear_to_end_of_line
153
- control('', 'K')
178
+ sig { returns(String) }
179
+ def clear_to_end_of_line
180
+ control('', 'K')
181
+ end
154
182
  end
155
183
  end
156
184
  end
data/lib/cli/ui/color.rb CHANGED
@@ -1,9 +1,17 @@
1
+ # typed: true
2
+
1
3
  require 'cli/ui'
2
4
 
3
5
  module CLI
4
6
  module UI
5
7
  class Color
6
- attr_reader :sgr, :name, :code
8
+ extend T::Sig
9
+
10
+ sig { returns(String) }
11
+ attr_reader :sgr, :code
12
+
13
+ sig { returns(Symbol) }
14
+ attr_reader :name
7
15
 
8
16
  # Creates a new color mapping
9
17
  # Signatures can be found here:
@@ -14,6 +22,7 @@ module CLI
14
22
  # * +sgr+ - The color signature
15
23
  # * +name+ - The name of the color
16
24
  #
25
+ sig { params(sgr: String, name: Symbol).void }
17
26
  def initialize(sgr, name)
18
27
  @sgr = sgr
19
28
  @code = CLI::UI::ANSI.sgr(sgr)
@@ -32,7 +41,7 @@ module CLI
32
41
  WHITE = new('97', :white)
33
42
 
34
43
  # 240 is very dark gray; 255 is very light gray. 244 is somewhat dark.
35
- GRAY = new('38;5;244', :grey)
44
+ GRAY = new('38;5;244', :gray)
36
45
 
37
46
  MAP = {
38
47
  red: RED,
@@ -47,11 +56,15 @@ module CLI
47
56
  }.freeze
48
57
 
49
58
  class InvalidColorName < ArgumentError
59
+ extend T::Sig
60
+
61
+ sig { params(name: Symbol).void }
50
62
  def initialize(name)
51
63
  super
52
64
  @name = name
53
65
  end
54
66
 
67
+ sig { returns(String) }
55
68
  def message
56
69
  keys = Color.available.map(&:inspect).join(',')
57
70
  "invalid color: #{@name.inspect} " \
@@ -59,25 +72,31 @@ module CLI
59
72
  end
60
73
  end
61
74
 
62
- # Looks up a color code by name
63
- #
64
- # ==== Raises
65
- # Raises a InvalidColorName if the color is not available
66
- # You likely need to add it to the +MAP+ or you made a typo
67
- #
68
- # ==== Returns
69
- # Returns a color code
70
- #
71
- def self.lookup(name)
72
- MAP.fetch(name)
73
- rescue KeyError
74
- raise InvalidColorName, name
75
- end
75
+ class << self
76
+ extend T::Sig
76
77
 
77
- # All available colors by name
78
- #
79
- def self.available
80
- MAP.keys
78
+ # Looks up a color code by name
79
+ #
80
+ # ==== Raises
81
+ # Raises a InvalidColorName if the color is not available
82
+ # You likely need to add it to the +MAP+ or you made a typo
83
+ #
84
+ # ==== Returns
85
+ # Returns a color code
86
+ #
87
+ sig { params(name: T.any(Symbol, String)).returns(Color) }
88
+ def lookup(name)
89
+ MAP.fetch(name.to_sym)
90
+ rescue KeyError
91
+ raise InvalidColorName, name
92
+ end
93
+
94
+ # All available colors by name
95
+ #
96
+ sig { returns(T::Array[Symbol]) }
97
+ def available
98
+ MAP.keys
99
+ end
81
100
  end
82
101
  end
83
102
  end
@@ -1,10 +1,14 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
3
+
2
4
  require('cli/ui')
3
5
  require('strscan')
4
6
 
5
7
  module CLI
6
8
  module UI
7
9
  class Formatter
10
+ extend T::Sig
11
+
8
12
  # Available mappings of formattings
9
13
  # To use any of them, you can use {{<key>:<string>}}
10
14
  # There are presentational (colours and formatters)
@@ -19,6 +23,8 @@ module CLI
19
23
  'blue' => '94', # 9x = high-intensity fg color x
20
24
  'magenta' => '35',
21
25
  'cyan' => '36',
26
+ 'gray' => '38;5;244',
27
+ 'white' => '97',
22
28
  'bold' => '1',
23
29
  'italic' => '3',
24
30
  'underline' => '4',
@@ -49,12 +55,21 @@ module CLI
49
55
 
50
56
  DISCARD_BRACES = 0..-3
51
57
 
52
- LITERAL_BRACES = :__literal_braces__
58
+ LITERAL_BRACES = Class.new
59
+
60
+ Stack = T.type_alias { T::Array[T.any(String, LITERAL_BRACES)] }
53
61
 
54
62
  class FormatError < StandardError
55
- attr_accessor :input, :index
63
+ extend T::Sig
64
+
65
+ sig { returns(String) }
66
+ attr_accessor :input
56
67
 
57
- def initialize(message = nil, input = nil, index = nil)
68
+ sig { returns(Integer) }
69
+ attr_accessor :index
70
+
71
+ sig { params(message: String, input: String, index: Integer).void }
72
+ def initialize(message, input, index)
58
73
  super(message)
59
74
  @input = input
60
75
  @index = index
@@ -67,8 +82,10 @@ module CLI
67
82
  #
68
83
  # * +text+ - the text to format
69
84
  #
85
+ sig { params(text: String).void }
70
86
  def initialize(text)
71
87
  @text = text
88
+ @nodes = T.let([], T::Array[[String, Stack]])
72
89
  end
73
90
 
74
91
  # Format the text using a map.
@@ -81,10 +98,11 @@ module CLI
81
98
  #
82
99
  # * +:enable_color+ - enable color output? Default is true unless output is redirected
83
100
  #
101
+ sig { params(sgr_map: T::Hash[String, String], enable_color: T::Boolean).returns(String) }
84
102
  def format(sgr_map = SGR_MAP, enable_color: CLI::UI.enable_color?)
85
- @nodes = []
103
+ @nodes.replace([])
86
104
  stack = parse_body(StringScanner.new(@text))
87
- prev_fmt = nil
105
+ prev_fmt = T.let(nil, T.nilable(Stack))
88
106
  content = @nodes.each_with_object(+'') do |(text, fmt), str|
89
107
  if prev_fmt != fmt && enable_color
90
108
  text = apply_format(text, fmt, sgr_map)
@@ -93,12 +111,12 @@ module CLI
93
111
  prev_fmt = fmt
94
112
  end
95
113
 
96
- stack.reject! { |e| e == LITERAL_BRACES }
114
+ stack.reject! { |e| e.is_a?(LITERAL_BRACES) }
97
115
 
98
116
  return content unless enable_color
99
117
  return content if stack == prev_fmt
100
118
 
101
- unless stack.empty? && (@nodes.size.zero? || @nodes.last[1].empty?)
119
+ unless stack.empty? && (@nodes.size.zero? || T.must(@nodes.last)[1].empty?)
102
120
  content << apply_format('', stack, sgr_map)
103
121
  end
104
122
  content
@@ -106,25 +124,28 @@ module CLI
106
124
 
107
125
  private
108
126
 
127
+ sig { params(text: String, fmt: Stack, sgr_map: T::Hash[String, String]).returns(String) }
109
128
  def apply_format(text, fmt, sgr_map)
110
129
  sgr = fmt.each_with_object(+'0') do |name, str|
111
- next if name == LITERAL_BRACES
130
+ next if name.is_a?(LITERAL_BRACES)
131
+
112
132
  begin
113
133
  str << ';' << sgr_map.fetch(name)
114
134
  rescue KeyError
115
135
  raise FormatError.new(
116
136
  "invalid format specifier: #{name}",
117
137
  @text,
118
- -1
138
+ -1,
119
139
  )
120
140
  end
121
141
  end
122
142
  CLI::UI::ANSI.sgr(sgr) + text
123
143
  end
124
144
 
145
+ sig { params(sc: StringScanner, stack: Stack).returns(Stack) }
125
146
  def parse_expr(sc, stack)
126
147
  if (match = sc.scan(SCAN_GLYPH))
127
- glyph_handle = match[0]
148
+ glyph_handle = T.must(match[0])
128
149
  begin
129
150
  glyph = Glyph.lookup(glyph_handle)
130
151
  emit(glyph.char, [glyph.color.name.to_s])
@@ -133,20 +154,20 @@ module CLI
133
154
  raise FormatError.new(
134
155
  "invalid glyph handle at index #{index}: '#{glyph_handle}'",
135
156
  @text,
136
- index
157
+ index,
137
158
  )
138
159
  end
139
160
  elsif (match = sc.scan(SCAN_WIDGET))
140
- match_data = SCAN_WIDGET.match(match) # Regexp.last_match doesn't work here
141
- widget_handle = match_data['handle']
161
+ match_data = T.must(SCAN_WIDGET.match(match)) # Regexp.last_match doesn't work here
162
+ widget_handle = T.must(match_data['handle'])
142
163
  begin
143
164
  widget = Widgets.lookup(widget_handle)
144
- emit(widget.call(match_data['args']), stack)
165
+ emit(widget.call(T.must(match_data['args'])), stack)
145
166
  rescue Widgets::InvalidWidgetHandle
146
167
  index = sc.pos - 2 # rewind past '}}'
147
168
  raise(FormatError.new(
148
169
  "invalid widget handle at index #{index}: '#{widget_handle}'",
149
- @text, index,
170
+ @text, index
150
171
  ))
151
172
  end
152
173
  elsif (match = sc.scan(SCAN_FUNCNAME))
@@ -158,20 +179,21 @@ module CLI
158
179
  # We do kind of assume that the text will probably have balanced
159
180
  # pairs of {{ }} at least.
160
181
  emit('{{', stack)
161
- stack.push(LITERAL_BRACES)
182
+ stack.push(LITERAL_BRACES.new)
162
183
  end
163
184
  parse_body(sc, stack)
164
185
  stack
165
186
  end
166
187
 
188
+ sig { params(sc: StringScanner, stack: Stack).returns(Stack) }
167
189
  def parse_body(sc, stack = [])
168
190
  match = sc.scan(SCAN_BODY)
169
191
  if match&.end_with?(BEGIN_EXPR)
170
- emit(match[DISCARD_BRACES], stack)
192
+ emit(T.must(match[DISCARD_BRACES]), stack)
171
193
  parse_expr(sc, stack)
172
194
  elsif match&.end_with?(END_EXPR)
173
- emit(match[DISCARD_BRACES], stack)
174
- if stack.pop == LITERAL_BRACES
195
+ emit(T.must(match[DISCARD_BRACES]), stack)
196
+ if stack.pop.is_a?(LITERAL_BRACES)
175
197
  emit('}}', stack)
176
198
  end
177
199
  parse_body(sc, stack)
@@ -183,9 +205,11 @@ module CLI
183
205
  stack
184
206
  end
185
207
 
208
+ sig { params(text: String, stack: Stack).void }
186
209
  def emit(text, stack)
187
- return if text.nil? || text.empty?
188
- @nodes << [text, stack.reject { |n| n == LITERAL_BRACES }]
210
+ return if text.empty?
211
+
212
+ @nodes << [text, stack.reject { |n| n.is_a?(LITERAL_BRACES) }]
189
213
  end
190
214
  end
191
215
  end