sai 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/ansi'
4
+ require 'strscan'
5
+
6
+ module Sai
7
+ module ANSI
8
+ # Extract ANSI sequence information from a string
9
+ #
10
+ # @author {https://aaronmallen.me Aaron Allen}
11
+ # @since 0.3.0
12
+ #
13
+ # @api private
14
+ class SequenceProcessor # rubocop:disable Metrics/ClassLength
15
+ # The pattern to extract ANSI sequences from a string
16
+ #
17
+ # @author {https://aaronmallen.me Aaron Allen}
18
+ # @since 0.3.0
19
+ #
20
+ # @api private
21
+ #
22
+ # @return [Regexp] the pattern
23
+ SEQUENCE_PATTERN = /\e\[([0-9;]*)m/ #: Regexp
24
+ private_constant :SEQUENCE_PATTERN
25
+
26
+ # Matches the code portion of style sequences
27
+ #
28
+ # @author {https://aaronmallen.me Aaron Allen}
29
+ # @since 0.3.0
30
+ #
31
+ # @api private
32
+ #
33
+ # @return [Regexp] the pattern
34
+ STYLE_CODE_PATTERN = /(?:[1-9]|2[1-9])/ #: Regexp
35
+ private_constant :STYLE_CODE_PATTERN
36
+
37
+ # Initialize a new instance of SequenceProcessor and parse the provided string
38
+ #
39
+ # @author {https://aaronmallen.me Aaron Allen}
40
+ # @since 0.3.0
41
+ #
42
+ # @api private
43
+ #
44
+ # @param string [String] the string to parse
45
+ #
46
+ # @return [Array<Hash{Symbol => Object}>] the segments
47
+ # @rbs (String string) -> Array[Hash[Symbol, untyped]]
48
+ def self.process(string)
49
+ new(string).process
50
+ end
51
+
52
+ # Initialize a new instance of SequenceProcessor
53
+ #
54
+ # @author {https://aaronmallen.me Aaron Allen}
55
+ # @since 0.3.0
56
+ #
57
+ # @api private
58
+ #
59
+ # @param string [String] the string to parse
60
+ #
61
+ # @return [SequenceProcessor] the new instance of SequenceProcessor
62
+ # @rbs (String string) -> void
63
+ def initialize(string)
64
+ @scanner = StringScanner.new(string)
65
+ @segments = [] #: Array[Hash[Symbol, untyped]]
66
+ @current_segment = blank_segment
67
+ @encoded_pos = 0
68
+ @stripped_pos = 0
69
+ end
70
+
71
+ # Parse a string and return a hash of segments
72
+ #
73
+ # @author {https://aaronmallen.me Aaron Allen}
74
+ # @since 0.3.0
75
+ #
76
+ # @api private
77
+ #
78
+ # @return [Array<Hash{Symbol => Object}>] the segments
79
+ # @rbs () -> Array[Hash[Symbol, untyped]]
80
+ def process
81
+ consume_tokens
82
+ finalize_segment_if_text!
83
+
84
+ @segments
85
+ end
86
+
87
+ private
88
+
89
+ # Applies 24-bit truecolor (e.g., 38;2;R;G;B or 48;2;R;G;B)
90
+ #
91
+ # @author {https://aaronmallen.me Aaron Allen}
92
+ # @since 0.3.0
93
+ #
94
+ # @api private
95
+ #
96
+ # @param codes_array [Array<Integer>]
97
+ # @param index [Integer]
98
+ #
99
+ # @return [Integer] the updated index (consumed 5 codes)
100
+ # @rbs (Array[Integer] codes_array, Integer index) -> Integer
101
+ def apply_24bit_color(codes_array, index)
102
+ base_code = codes_array[index]
103
+ r = codes_array[index + 2]
104
+ g = codes_array[index + 3]
105
+ b = codes_array[index + 4]
106
+
107
+ if base_code == 38
108
+ @current_segment[:foreground] = "38;2;#{r};#{g};#{b}"
109
+ else
110
+ @current_segment[:background] = "48;2;#{r};#{g};#{b}"
111
+ end
112
+ index + 5
113
+ end
114
+
115
+ # Applies 256-color mode (e.g., 38;5;160 or 48;5;21)
116
+ #
117
+ # @author {https://aaronmallen.me Aaron Allen}
118
+ # @since 0.3.0
119
+ #
120
+ # @api private
121
+ #
122
+ # @param codes_array [Array<Integer>]
123
+ # @param index [Integer]
124
+ #
125
+ # @return [Integer] the updated index (consumed 3 codes)
126
+ # @rbs (Array[Integer] codes_array, Integer index) -> Integer
127
+ def apply_256_color(codes_array, index)
128
+ base_code = codes_array[index]
129
+ color_number = codes_array[index + 2]
130
+ if base_code == 38
131
+ @current_segment[:foreground] = "38;5;#{color_number}"
132
+ else
133
+ @current_segment[:background] = "48;5;#{color_number}"
134
+ end
135
+ index + 3 # consumed 3 codes total
136
+ end
137
+
138
+ # Applies the appropriate action for the provided ANSI sequence
139
+ #
140
+ # @author {https://aaronmallen.me Aaron Allen}
141
+ # @since 0.3.0
142
+ #
143
+ # @api private
144
+ #
145
+ # @param sequence [String] an ANSI sequence (e.g. "\e[31m", "\e[0m")
146
+ #
147
+ # @return [void]
148
+ # @rbs (String sequence) -> void
149
+ def apply_ansi_sequence(sequence)
150
+ return reset_segment! if sequence == ANSI::RESET
151
+
152
+ codes = sequence.match(SEQUENCE_PATTERN)&.[](1)
153
+ return unless codes
154
+
155
+ apply_codes(codes)
156
+ end
157
+
158
+ # Applies a basic color (FG or BG) in the range 30..37 (FG) or 40..47 (BG)
159
+ #
160
+ # @author {https://aaronmallen.me Aaron Allen}
161
+ # @since 0.3.0
162
+ #
163
+ # @api private
164
+ #
165
+ # @param code [Integer] the numeric color code
166
+ #
167
+ # @return [void]
168
+ # @rbs (Integer code) -> void
169
+ def apply_basic_color(code)
170
+ if (30..37).cover?(code)
171
+ @current_segment[:foreground] = code.to_s
172
+ else # 40..47
173
+ @current_segment[:background] = code.to_s
174
+ end
175
+ end
176
+
177
+ # Parse all numeric codes in the provided string, applying them in order (just like a real ANSI terminal)
178
+ #
179
+ # @author {https://aaronmallen.me Aaron Allen}
180
+ # @since 0.3.0
181
+ #
182
+ # @api private
183
+ #
184
+ # @param codes_string [String] e.g. "38;5;160;48;5;21;1"
185
+ #
186
+ # @return [void]
187
+ # @rbs (String codes_string) -> void
188
+ def apply_codes(codes_string)
189
+ codes_array = codes_string.split(';').map(&:to_i)
190
+ i = 0
191
+ i = apply_single_code(codes_array, i) while i < codes_array.size
192
+ end
193
+
194
+ # Applies a single code (or group) from the array. This might be:
195
+ # - 0 => reset
196
+ # - 30..37 => basic FG color
197
+ # - 40..47 => basic BG color
198
+ # - 38 or 48 => extended color sequence
199
+ # - otherwise => style code (bold, underline, etc.)
200
+ #
201
+ # @author {https://aaronmallen.me Aaron Allen}
202
+ # @since 0.3.0
203
+ #
204
+ # @api private
205
+ #
206
+ # @param codes_array [Array<Integer>] the list of numeric codes
207
+ # @param index [Integer] the current index
208
+ #
209
+ # @return [Integer] the updated index after consuming needed codes
210
+ # @rbs (Array[Integer] codes_array, Integer index) -> Integer
211
+ def apply_single_code(codes_array, index) # rubocop:disable Metrics/MethodLength
212
+ code = codes_array[index]
213
+ return index + 1 if code.nil?
214
+
215
+ case code
216
+ when 0
217
+ reset_segment!
218
+ index + 1
219
+ when 30..37, 40..47
220
+ apply_basic_color(code)
221
+ index + 1
222
+ when 38, 48
223
+ parse_extended_color(codes_array, index)
224
+ else
225
+ apply_style_code(code)
226
+ index + 1
227
+ end
228
+ end
229
+
230
+ # Applies a single style code (e.g. 1=bold, 2=dim, 4=underline, etc.) if it matches
231
+ #
232
+ # @author {https://aaronmallen.me Aaron Allen}
233
+ # @since 0.3.0
234
+ #
235
+ # @api private
236
+ #
237
+ # @param code [Integer] the numeric code to check
238
+ #
239
+ # @return [void]
240
+ # @rbs (Integer code) -> void
241
+ def apply_style_code(code)
242
+ # If it matches the existing style pattern, add it to @current_segment[:styles]
243
+ # Typically: 1..9, or 21..29, etc. (Your STYLE_CODE_PATTERN is /(?:[1-9]|2[1-9])/)
244
+ return unless code.to_s.match?(STYLE_CODE_PATTERN)
245
+
246
+ @current_segment[:styles] << code.to_s
247
+ end
248
+
249
+ # Creates and returns a fresh, blank segment
250
+ #
251
+ # @author {https://aaronmallen.me Aaron Allen}
252
+ # @since 0.3.0
253
+ #
254
+ # @api private
255
+ #
256
+ # @return [Hash{Symbol => Object}] a new, empty segment
257
+ # @rbs () -> Hash[Symbol, untyped]
258
+ def blank_segment
259
+ { text: +'', foreground: nil, background: nil, styles: [] }
260
+ end
261
+
262
+ # Scans the string for ANSI sequences or individual characters
263
+ #
264
+ # @author {https://aaronmallen.me Aaron Allen}
265
+ # @since 0.3.0
266
+ #
267
+ # @api private
268
+ #
269
+ # @return [void]
270
+ # @rbs () -> void
271
+ def consume_tokens
272
+ handle_ansi_sequence || handle_character until @scanner.eos?
273
+ end
274
+
275
+ # Finalizes the current segment if any text is present, then resets it
276
+ #
277
+ # @author {https://aaronmallen.me Aaron Allen}
278
+ # @since 0.3.0
279
+ #
280
+ # @api private
281
+ #
282
+ # @return [void]
283
+ # @rbs () -> void
284
+ def finalize_segment_if_text!
285
+ return if @current_segment[:text].empty?
286
+
287
+ seg_len = @current_segment[:text].length
288
+ segment = @current_segment.merge(
289
+ encoded_start: @encoded_pos - seg_len,
290
+ encoded_end: @encoded_pos,
291
+ stripped_start: @stripped_pos - seg_len,
292
+ stripped_end: @stripped_pos
293
+ )
294
+
295
+ @segments << segment
296
+ reset_segment!
297
+ end
298
+
299
+ # Attempts to capture an ANSI sequence from the scanner If found, finalizes
300
+ # the current text segment and applies the sequence
301
+ #
302
+ # @author {https://aaronmallen.me Aaron Allen}
303
+ # @since 0.3.0
304
+ #
305
+ # @api private
306
+ #
307
+ # @return [Boolean] `true` if a sequence was found, `false` if otherwise
308
+ # @rbs () -> bool
309
+ def handle_ansi_sequence
310
+ sequence = @scanner.scan(SEQUENCE_PATTERN)
311
+ return false unless sequence
312
+
313
+ finalize_segment_if_text!
314
+ apply_ansi_sequence(sequence)
315
+ @encoded_pos += sequence.length
316
+ true
317
+ end
318
+
319
+ # Reads a single character from the scanner and appends it to the current segment
320
+ #
321
+ # @author {https://aaronmallen.me Aaron Allen}
322
+ # @since 0.3.0
323
+ #
324
+ # @api private
325
+ #
326
+ # @return [void]
327
+ # @rbs () -> void
328
+ def handle_character
329
+ char = @scanner.getch
330
+ @current_segment[:text] << char
331
+ @encoded_pos += 1
332
+ @stripped_pos += 1
333
+ end
334
+
335
+ # Parse extended color codes from the array, e.g. 38;5;160 (256-color) or 38;2;R;G;B (24-bit),
336
+ # and apply them to foreground or background
337
+ #
338
+ # @author {https://aaronmallen.me Aaron Allen}
339
+ # @since 0.3.0
340
+ #
341
+ # @api private
342
+ #
343
+ # @param codes_array [Array<Integer>] the array of codes
344
+ # @param index [Integer] the current position (where we saw 38 or 48)
345
+ #
346
+ # @return [Integer] the updated position in the codes array
347
+ # @rbs (Array[Integer] codes_array, Integer index) -> Integer
348
+ def parse_extended_color(codes_array, index)
349
+ mode_code = codes_array[index + 1]
350
+
351
+ return index + 1 unless mode_code
352
+
353
+ case mode_code
354
+ when 5
355
+ apply_256_color(codes_array, index)
356
+ when 2
357
+ apply_24bit_color(codes_array, index)
358
+ else
359
+ index + 1
360
+ end
361
+ end
362
+
363
+ # Resets the current segment to a fresh, blank state
364
+ #
365
+ # @author {https://aaronmallen.me Aaron Allen}
366
+ # @since 0.3.0
367
+ #
368
+ # @api private
369
+ #
370
+ # @return [void]
371
+ # @rbs () -> void
372
+ def reset_segment!
373
+ @current_segment[:text] = +''
374
+ @current_segment[:foreground] = nil
375
+ @current_segment[:background] = nil
376
+ @current_segment[:styles] = []
377
+ end
378
+ end
379
+ end
380
+ end