sai 0.1.0 → 0.3.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,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