tans-parser 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f88d3f3ba4bc87f81c46fb3168b660728f980e5fa97daf608ba9f68e35f2bdf
4
+ data.tar.gz: 5b535136d317e598098cca288e7f8f157f0d0d45b6974f437572860d5554cb14
5
+ SHA512:
6
+ metadata.gz: 32f9a060a089b2d8771b249e5a548b1725498d6b1b1a101cc8cbd547a41939fc8683d93853c734a8eb3f7edcbce6e5cb0fc3af162207727b02ac5f8850e5a5b5
7
+ data.tar.gz: 34481b9990055c0ba0fdd4d972703cbcab3208360ad6e4877cc0953e3f9de91838095aa5e9f7c54b4e3e563820d5e1b752a3925883021fdda9b45dd0f882862d
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # CHANGELOG
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: ANSI escape sequence parser extracted from tui-td
6
+ - `TansParser::ANSIParser` — parses raw terminal output into structured state (735 lines)
7
+ - `TansParser::ANSIUtils` — shared ANSI color and style helpers (77 lines)
8
+ - `TansParser::State` — high-level query API for terminal state (148 lines)
9
+ - Zero runtime dependencies (pure Ruby stdlib)
10
+ - 188 tests with 100% line and branch coverage
11
+ - SGR colors (16, 256, TrueColor), cursor movement, erase, scroll
12
+ - Alternate screen buffer support (DEC private modes 47, 1047, 1049)
13
+ - ISO-2022 charset switching (G0/G1, DEC Special Character & Line Drawing)
14
+ - Mouse tracking mode/format parsing (1000, 1002, 1003, 1006)
15
+ - DECSC/DECRC cursor save/restore (ESC 7/8 and CSI s/u variants)
16
+ - DECSTBM scroll region support
17
+ - DSR (Device Status Report) detection
18
+ - `build_frame` — reconstruct ANSI output from state hash
19
+ - `_color_code` — convert named/hex colors to ANSI color sequences
20
+ - UTF-8 multi-byte character support
21
+ - RuboCop, Reek, Bundler-Audit configured
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haluk Durmus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # tans-parser — Terminal ANSI State Utils
2
+
3
+ Parse raw terminal output with ANSI escape sequences into structured, queryable data.
4
+
5
+ Zero runtime dependencies. Ruby stdlib only.
6
+
7
+ ## Installation
8
+
9
+ Ruby 3.0+ required.
10
+
11
+ ```bash
12
+ gem install tans-parser
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Parse ANSI output
18
+
19
+ ```ruby
20
+ require "tans-parser"
21
+
22
+ # Parse a raw ANSI string into a structured grid
23
+ raw = "\e[31mERROR:\e[0m Something went wrong\n\e[32mOK:\e[0m All good"
24
+ state_data = TansParser::ANSIParser.parse(raw, rows: 40, cols: 120)
25
+
26
+ # state_data is a Hash with:
27
+ # :size → {rows:, cols:}
28
+ # :cursor → {row:, col:, visible:, style:}
29
+ # :rows → [[{char:, fg:, bg:, bold:, italic:, underline:, blink:}, ...], ...]
30
+ ```
31
+
32
+ ### Query the state
33
+
34
+ ```ruby
35
+ state = TansParser::State.new(state_data)
36
+
37
+ # Plain text of the entire screen
38
+ state.plain_text
39
+ # => "ERROR: Something went wrong\nOK: All good"
40
+
41
+ # Search for text
42
+ state.find_text("ERROR")
43
+ # => [{row: 0, col: 0, text: "ERROR", full_line: "ERROR: Something went wrong"}]
44
+
45
+ # Cell-level queries
46
+ state.foreground_at(0, 0) # => "red"
47
+ state.background_at(0, 0) # => "default"
48
+ state.style_at(0, 0) # => {bold: false, italic: false, underline: false}
49
+
50
+ # AI-friendly JSON with highlights
51
+ state.to_ai_json
52
+ # => {size:, cursor:, text:, highlights:, summary:}
53
+ ```
54
+
55
+ ### Rebuild ANSI from state
56
+
57
+ ```ruby
58
+ ansi = TansParser::ANSIParser.build_frame(state_data)
59
+ # => "\e[0m\e[2J\e[H\e[31mE\e[31mR\e[31mR..."
60
+ ```
61
+
62
+ ### Color utilities
63
+
64
+ ```ruby
65
+ include TansParser::ANSIUtils
66
+
67
+ resolve_color("red", nil) # => [0xAA, 0x00, 0x00]
68
+ resolve_color("#ff8800", nil) # => [255, 136, 0]
69
+ resolve_color("color82", nil) # => [95, 255, 0]
70
+ xterm_256(16) # => [0x00, 0x00, 0x00]
71
+ ```
72
+
73
+ ## Cell format
74
+
75
+ Each cell is a Hash with these keys:
76
+
77
+ | Key | Type | Description |
78
+ |-----|------|-------------|
79
+ | `char` | String | Single character (UTF-8) |
80
+ | `fg` | String | Foreground color name, hex, or "colorN" |
81
+ | `bg` | String | Background color name, hex, or "colorN" |
82
+ | `bold` | Boolean | Bold style |
83
+ | `italic` | Boolean | Italic style |
84
+ | `underline` | Boolean | Underline style |
85
+ | `blink` | Boolean | Blink style |
86
+
87
+ Default cell: `{char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false}`
88
+
89
+ ## Supported ANSI sequences
90
+
91
+ - **SGR** — colors (16, 256, TrueColor), bold, italic, underline, blink, reverse
92
+ - **Cursor** — CUU, CUD, CUF, CUB, CUP, CHA
93
+ - **Erase** — ED (erase display), EL (erase line), ECH (erase characters)
94
+ - **Scroll** — scroll regions (DECSTBM), overflow scrolling
95
+ - **Alt screen** — DEC private modes 47, 1047, 1049
96
+ - **Cursor save/restore** — DECSC, DECRC, CSI s, CSI u
97
+ - **Cursor style** — DECSCUSR
98
+ - **Mouse tracking** — DEC private modes 1000, 1002, 1003, 1006
99
+ - **ISO 2022** — G0/G1 charset switching, DEC Special Graphics
100
+ - **UTF-8** — Multi-byte characters including emoji
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TansParser
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require_relative "tans_parser/version"
8
+ require_relative "tans_parser/ansi_parser"
9
+ require_relative "tans_parser/ansi_utils"
10
+ require_relative "tans_parser/state"
@@ -0,0 +1,736 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TansParser
4
+ # Parses raw terminal output (ANSI escape sequences + text) into a
5
+ # structured state representation.
6
+ #
7
+ # Handles:
8
+ # - SGR (Select Graphic Rendition) — colors, bold, italic, underline
9
+ # - Cursor movement (CUU, CUD, CUF, CUB, CUP)
10
+ # - Erase (ED, EL)
11
+ # - Line feed, carriage return, backspace, tab
12
+ #
13
+ # Output: {rows: [[{char, fg, bg, bold, italic, underline}]], cursor: {row, col}, size: {rows, cols}}
14
+ #
15
+ # rubocop:disable Metrics/ModuleLength
16
+ module ANSIParser
17
+ SGR_COLORS = {
18
+ 0 => :reset,
19
+ 1 => :bold,
20
+ 3 => :italic,
21
+ 4 => :underline,
22
+ 5 => :blink,
23
+ 7 => :reverse,
24
+ 22 => :normal,
25
+ 23 => :no_italic,
26
+ 24 => :no_underline,
27
+ 30 => :black,
28
+ 31 => :red,
29
+ 32 => :green,
30
+ 33 => :yellow,
31
+ 34 => :blue,
32
+ 35 => :magenta,
33
+ 36 => :cyan,
34
+ 37 => :white,
35
+ 38 => :xterm_fg, # 38;5;N or 38;2;R;G;B
36
+ 39 => :default_fg,
37
+ 40 => :bg_black,
38
+ 41 => :bg_red,
39
+ 42 => :bg_green,
40
+ 43 => :bg_yellow,
41
+ 44 => :bg_blue,
42
+ 45 => :bg_magenta,
43
+ 46 => :bg_cyan,
44
+ 47 => :bg_white,
45
+ 48 => :xterm_bg, # 48;5;N or 48;2;R;G;B
46
+ 49 => :default_bg,
47
+ 90 => :bright_black,
48
+ 91 => :bright_red,
49
+ 92 => :bright_green,
50
+ 93 => :bright_yellow,
51
+ 94 => :bright_blue,
52
+ 95 => :bright_magenta,
53
+ 96 => :bright_cyan,
54
+ 97 => :bright_white,
55
+ 100 => :bg_bright_black,
56
+ 101 => :bg_bright_red,
57
+ 102 => :bg_bright_green,
58
+ 103 => :bg_bright_yellow,
59
+ 104 => :bg_bright_blue,
60
+ 105 => :bg_bright_magenta,
61
+ 106 => :bg_bright_cyan,
62
+ 107 => :bg_bright_white,
63
+ }.freeze
64
+
65
+ SGR_16_TO_NAME = {
66
+ 0 => "black",
67
+ 1 => "red",
68
+ 2 => "green",
69
+ 3 => "yellow",
70
+ 4 => "blue",
71
+ 5 => "magenta",
72
+ 6 => "cyan",
73
+ 7 => "white",
74
+ 8 => "bright_black",
75
+ 9 => "bright_red",
76
+ 10 => "bright_green",
77
+ 11 => "bright_yellow",
78
+ 12 => "bright_blue",
79
+ 13 => "bright_magenta",
80
+ 14 => "bright_cyan",
81
+ 15 => "bright_white",
82
+ }.freeze
83
+
84
+ DEC_MAP = {
85
+ "`" => "◆",
86
+ "a" => "▒",
87
+ "b" => "\u2409",
88
+ "c" => "\u240C",
89
+ "d" => "\u240D",
90
+ "e" => "\u240A",
91
+ "f" => "°",
92
+ "g" => "±",
93
+ "h" => "\u2424",
94
+ "i" => "\u240B",
95
+ "j" => "┘",
96
+ "k" => "┐",
97
+ "l" => "┌",
98
+ "m" => "└",
99
+ "n" => "┼",
100
+ "o" => "⎺",
101
+ "p" => "⎻",
102
+ "q" => "─",
103
+ "r" => "⎼",
104
+ "s" => "⎽",
105
+ "t" => "├",
106
+ "u" => "┤",
107
+ "v" => "┴",
108
+ "w" => "┬",
109
+ "x" => "│",
110
+ "y" => "≤",
111
+ "z" => "≥",
112
+ "{" => "π",
113
+ "|" => "≠",
114
+ "}" => "£",
115
+ "~" => "·",
116
+ }.freeze
117
+
118
+ # Parse raw terminal output into a structured state Hash
119
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
120
+ def self.parse(raw, rows = 40, cols = 120)
121
+ grid = Array.new(rows) do
122
+ Array.new(cols) { default_cell.dup }
123
+ end
124
+
125
+ cursor = { row: 0, col: 0 }
126
+ attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
127
+ saved_cursor = nil
128
+ scroll_region = { top: 0, bottom: rows - 1 }
129
+ pending_dsr = false
130
+
131
+ normal_grid = grid
132
+ alt_grid = nil
133
+
134
+ normal_cursor = cursor
135
+ alt_cursor = { row: 0, col: 0 }
136
+
137
+ normal_saved_cursor = nil
138
+ alt_saved_cursor = nil
139
+
140
+ use_alt_screen = false
141
+
142
+ cursor_visible = true
143
+ cursor_style = 1 # 1 = blinking block (default)
144
+
145
+ g0_charset = :ascii
146
+ g1_charset = :dec
147
+ active_charset = :g0
148
+
149
+ mouse_mode = :none
150
+ mouse_format = :normal
151
+
152
+ # Strip everything before the last full clear (if any)
153
+ # to avoid accumulated garbage
154
+ processed = raw
155
+
156
+ i = 0
157
+ while i < processed.length
158
+ if processed[i] == "\e" && processed[i + 1] == "["
159
+ # Find end of CSI sequence
160
+ j = i + 2
161
+ j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnRrsuq]/)
162
+ seq = processed[i..j]
163
+
164
+ dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
165
+ pending_dsr ||= dsr
166
+
167
+ if new_saved
168
+ if use_alt_screen
169
+ alt_saved_cursor = new_saved
170
+ saved_cursor = alt_saved_cursor
171
+ else
172
+ normal_saved_cursor = new_saved
173
+ saved_cursor = normal_saved_cursor
174
+ end
175
+ end
176
+
177
+ if action.key?(:alt_screen)
178
+ new_alt = action[:alt_screen]
179
+ code = action[:alt_screen_code]
180
+ if new_alt != use_alt_screen
181
+ if new_alt
182
+ # Switch to Alternate Screen
183
+ # Save normal cursor
184
+ normal_cursor = { row: cursor[:row], col: cursor[:col] }
185
+
186
+ # Lazy initialize alt grid
187
+ alt_grid ||= Array.new(rows) do
188
+ Array.new(cols) { default_cell.dup }
189
+ end
190
+
191
+ # For \e[?1049h, clear alternate screen and reset cursor to 0,0
192
+ if code == 1049
193
+ alt_grid = Array.new(rows) do
194
+ Array.new(cols) { default_cell.dup }
195
+ end
196
+ alt_cursor = { row: 0, col: 0 }
197
+ end
198
+
199
+ grid = alt_grid
200
+ cursor = alt_cursor
201
+ saved_cursor = alt_saved_cursor
202
+ use_alt_screen = true
203
+ else
204
+ # Switch to Normal Screen
205
+ # Save alt cursor
206
+ alt_cursor = { row: cursor[:row], col: cursor[:col] }
207
+
208
+ grid = normal_grid
209
+ cursor = normal_cursor
210
+ saved_cursor = normal_saved_cursor
211
+ use_alt_screen = false
212
+ end
213
+ end
214
+ end
215
+
216
+ cursor_visible = action[:cursor_visible] if action.key?(:cursor_visible)
217
+
218
+ cursor_style = action[:cursor_style] if action.key?(:cursor_style)
219
+
220
+ mouse_mode = action[:mouse_mode] if action.key?(:mouse_mode)
221
+
222
+ mouse_format = action[:mouse_format] if action.key?(:mouse_format)
223
+
224
+ i = j + 1
225
+ elsif processed[i] == "\n"
226
+ cursor[:row] += 1
227
+ cursor[:col] = 0
228
+ i += 1
229
+ elsif processed[i] == "\r"
230
+ cursor[:col] = 0
231
+ i += 1
232
+ elsif processed[i] == "\t"
233
+ cursor[:col] = ((cursor[:col] / 8) + 1) * 8
234
+ cursor[:col] = cols - 1 if cursor[:col] >= cols
235
+ i += 1
236
+ elsif processed[i] == "\b"
237
+ cursor[:col] -= 1 if cursor[:col].positive?
238
+ i += 1
239
+ elsif processed[i] == "\a"
240
+ # Bell — ignore
241
+ i += 1
242
+ elsif processed[i] == "\x0e"
243
+ active_charset = :g1
244
+ i += 1
245
+ elsif processed[i] == "\x0f"
246
+ active_charset = :g0
247
+ i += 1
248
+ elsif processed[i] == "\e"
249
+ # Handle non-CSI escape sequences
250
+ if processed[i + 1] == "7"
251
+ # DECSC — Save Cursor
252
+ if use_alt_screen
253
+ alt_saved_cursor = { row: cursor[:row], col: cursor[:col] }
254
+ saved_cursor = alt_saved_cursor
255
+ else
256
+ normal_saved_cursor = { row: cursor[:row], col: cursor[:col] }
257
+ saved_cursor = normal_saved_cursor
258
+ end
259
+ i += 2
260
+ elsif processed[i + 1] == "8"
261
+ # DECRC — Restore Cursor
262
+ if saved_cursor
263
+ cursor[:row] = saved_cursor[:row]
264
+ cursor[:col] = saved_cursor[:col]
265
+ end
266
+ i += 2
267
+ elsif processed[i + 1] == "(" && %w[0 B].include?(processed[i + 2])
268
+ g0_charset = (processed[i + 2] == "0" ? :dec : :ascii)
269
+ i += 3
270
+ elsif processed[i + 1] == ")" && %w[0 B].include?(processed[i + 2])
271
+ g1_charset = (processed[i + 2] == "0" ? :dec : :ascii)
272
+ i += 3
273
+ elsif processed[i + 1]&.match?(%r{[()*+\-./]})
274
+ # Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
275
+ i += 3
276
+ else
277
+ i += 1
278
+ end
279
+ elsif (char, char_len = _utf8_char_at(processed, i))
280
+ # Printable character (including multi-byte UTF-8)
281
+ # cursor row/col are always clamped within bounds
282
+ cell = grid[cursor[:row]][cursor[:col]]
283
+ current_charset = (active_charset == :g1 ? g1_charset : g0_charset)
284
+ mapped_char = char
285
+ mapped_char = DEC_MAP[char] if current_charset == :dec && DEC_MAP.key?(char)
286
+ cell[:char] = mapped_char
287
+ cell.merge!(attrs)
288
+ cursor[:col] += 1
289
+ cursor[:col] = cols - 1 if cursor[:col] >= cols
290
+ i += char_len
291
+ else # rubocop:disable Lint/DuplicateBranch
292
+ i += 1
293
+ end
294
+
295
+ # Handle scrolling within the defined scroll region
296
+ region_top = scroll_region[:top]
297
+ region_bottom = scroll_region[:bottom]
298
+
299
+ next unless cursor[:row] > region_bottom
300
+
301
+ scroll_lines = [cursor[:row] - region_bottom, rows].min
302
+ # Shift lines within the scroll region up
303
+ (region_top..(region_bottom - scroll_lines)).each do |ri|
304
+ src = ri + scroll_lines
305
+ grid[ri] = grid[src]
306
+ end
307
+ # Fill bottom of scroll region with blank lines
308
+ ((region_bottom - scroll_lines + 1)..region_bottom).each do |ri|
309
+ grid[ri] = Array.new(cols) { default_cell.dup }
310
+ end
311
+ cursor[:row] = region_bottom
312
+ end
313
+
314
+ {
315
+ size: { rows: rows, cols: cols },
316
+ cursor: {
317
+ row: cursor[:row],
318
+ col: cursor[:col],
319
+ visible: cursor_visible,
320
+ style: cursor_style,
321
+ },
322
+ rows: grid,
323
+ pending_dsr: pending_dsr,
324
+ cursor_visible: cursor_visible,
325
+ cursor_style: cursor_style,
326
+ mouse_mode: mouse_mode,
327
+ mouse_format: mouse_format,
328
+ }
329
+ end
330
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
331
+
332
+ # Rebuild ANSI output from a state hash (for rendering/screenshot)
333
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
334
+ def self.build_frame(state)
335
+ rows = state.dig(:size, :rows) || state["size"]["rows"]
336
+ state.dig(:size, :cols) || state["size"]["cols"]
337
+ grid = state[:rows] || state["rows"]
338
+ cursor = state[:cursor] || state["cursor"]
339
+ mouse_mode = state[:mouse_mode] || state["mouse_mode"] || :none
340
+ mouse_format = state[:mouse_format] || state["mouse_format"] || :normal
341
+
342
+ out = +""
343
+ out << "\e[0m"
344
+ out << "\e[2J\e[H"
345
+
346
+ grid.each_with_index do |row, ri|
347
+ row.each_with_index do |cell, _ci|
348
+ char = cell[:char] || cell["char"] || " "
349
+ fg = cell[:fg] || cell["fg"] || "default"
350
+ bg = cell[:bg] || cell["bg"] || "default"
351
+ bold = cell[:bold] || cell["bold"] || false
352
+ italic = cell[:italic] || cell["italic"] || false
353
+ underline = cell[:underline] || cell["underline"] || false
354
+ blink = cell[:blink] || cell["blink"] || false
355
+
356
+ codes = []
357
+ codes << "1" if bold
358
+ codes << "3" if italic
359
+ codes << "4" if underline
360
+ codes << "5" if blink
361
+
362
+ fg_code = _color_code(fg, "38")
363
+ bg_code = _color_code(bg, "48")
364
+
365
+ codes << fg_code if fg_code
366
+ codes << bg_code if bg_code
367
+
368
+ out << "\e[#{codes.join(";")}m" unless codes.empty?
369
+ out << char
370
+ end
371
+ out << "\n" if ri < rows - 1
372
+ end
373
+
374
+ # Reconstruct cursor visibility
375
+ cursor_vis = true
376
+ cursor_vis = cursor[:visible] != false && cursor["visible"] != false if cursor.is_a?(Hash)
377
+ out << (cursor_vis ? "\e[?25h" : "\e[?25l")
378
+
379
+ # Reconstruct cursor style
380
+ if cursor.is_a?(Hash)
381
+ style = cursor[:style] || cursor["style"]
382
+ out << "\e[#{style} q" if style
383
+ end
384
+
385
+ # Reconstruct mouse mode and format
386
+ out << case mouse_mode
387
+ when :normal
388
+ "\e[?1000h"
389
+ when :drag
390
+ "\e[?1002h"
391
+ when :all
392
+ "\e[?1003h"
393
+ else
394
+ "\e[?1000l\e[?1002l\e[?1003l"
395
+ end
396
+
397
+ out << if mouse_format == :sgr
398
+ "\e[?1006h"
399
+ else
400
+ "\e[?1006l"
401
+ end
402
+
403
+ out << "\e[0m"
404
+ out
405
+ end
406
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
407
+
408
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
409
+ def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
410
+ # Strip leading escape char if present
411
+ cleaned = seq.sub(/^\e/, "")
412
+ match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@`fhlmnRrsuq])$/)
413
+ return [false, nil, {}] unless match
414
+
415
+ is_private = (match[1] == "?")
416
+ params = match[2].split(";").map(&:to_i)
417
+ match[3]
418
+ command = match[4]
419
+
420
+ new_saved = nil
421
+ action = {}
422
+
423
+ case command
424
+ when "m"
425
+ _apply_sgr(params, attrs)
426
+ when "A" # CUU — Cursor Up
427
+ n = params[0] || 1
428
+ n = 1 if n.zero?
429
+ cursor[:row] = [cursor[:row] - n, 0].max
430
+ when "B" # CUD — Cursor Down
431
+ n = params[0] || 1
432
+ n = 1 if n.zero?
433
+ cursor[:row] = [cursor[:row] + n, rows - 1].min
434
+ when "C" # CUF — Cursor Forward
435
+ n = params[0] || 1
436
+ n = 1 if n.zero?
437
+ cursor[:col] = [cursor[:col] + n, cols - 1].min
438
+ when "D" # CUB — Cursor Back
439
+ n = params[0] || 1
440
+ n = 1 if n.zero?
441
+ cursor[:col] = [cursor[:col] - n, 0].max
442
+ when "H", "f" # CUP — Cursor Position
443
+ r = (params[0] || 1) - 1
444
+ c = (params[1] || 1) - 1
445
+ cursor[:row] = r.clamp(0, rows - 1)
446
+ cursor[:col] = c.clamp(0, cols - 1)
447
+ when "J" # ED — Erase in Display
448
+ case params[0]
449
+ when nil, 0
450
+ _erase_down(cursor, grid, rows, cols)
451
+ when 1
452
+ _erase_up(cursor, grid, cols)
453
+ when 2, 3
454
+ _erase_all(grid, rows, cols)
455
+ cursor[:row] = 0
456
+ cursor[:col] = 0
457
+ end
458
+ when "K" # EL — Erase in Line
459
+ case params[0]
460
+ when nil, 0
461
+ _erase_line_right(cursor, grid, cols)
462
+ when 1
463
+ _erase_line_left(cursor, grid, cols)
464
+ when 2
465
+ _erase_line(cursor, grid, cols)
466
+ end
467
+ when "X" # Erase Characters
468
+ n = params[0] || 1
469
+ n.times do |i|
470
+ next unless cursor[:row] < rows && cursor[:col] + i < cols
471
+
472
+ grid[cursor[:row]][cursor[:col] + i][:char] = " "
473
+ end
474
+ when "s" # DECSC — Save Cursor (CSI variant)
475
+ new_saved = { row: cursor[:row], col: cursor[:col] }
476
+ when "u" # DECRC — Restore Cursor (CSI variant)
477
+ if saved_cursor
478
+ cursor[:row] = saved_cursor[:row]
479
+ cursor[:col] = saved_cursor[:col]
480
+ end
481
+ when "r" # DECSTBM — Set Scroll Region
482
+ top = (params[0] || 1) - 1
483
+ bottom = (params[1] || rows) - 1
484
+ top = top.clamp(0, rows - 1)
485
+ bottom = bottom.clamp(0, rows - 1)
486
+ if top < bottom
487
+ scroll_region[:top] = top
488
+ scroll_region[:bottom] = bottom
489
+ else
490
+ scroll_region[:top] = 0
491
+ scroll_region[:bottom] = rows - 1
492
+ end
493
+ cursor[:row] = 0
494
+ cursor[:col] = 0
495
+ when "h"
496
+ if is_private
497
+ params.each do |p|
498
+ case p
499
+ when 47, 1047, 1049
500
+ action[:alt_screen] = true
501
+ action[:alt_screen_code] = p
502
+ when 25
503
+ action[:cursor_visible] = true
504
+ when 1000
505
+ action[:mouse_mode] = :normal
506
+ when 1002
507
+ action[:mouse_mode] = :drag
508
+ when 1003
509
+ action[:mouse_mode] = :all
510
+ when 1006
511
+ action[:mouse_format] = :sgr
512
+ end
513
+ end
514
+ end
515
+ when "l"
516
+ if is_private
517
+ params.each do |p|
518
+ case p
519
+ when 47, 1047, 1049
520
+ action[:alt_screen] = false
521
+ action[:alt_screen_code] = p
522
+ when 25
523
+ action[:cursor_visible] = false
524
+ when 1000, 1002, 1003
525
+ action[:mouse_mode] = :none
526
+ when 1006
527
+ action[:mouse_format] = :normal
528
+ end
529
+ end
530
+ end
531
+ when "q"
532
+ style_val = params[0] || 0
533
+ action[:cursor_style] = style_val
534
+ when "n" # DSR — Device Status Report request
535
+ return [params[0] == 6, nil, {}]
536
+ when "R" # DSR response (from terminal side) or CPR — ignore
537
+ nil
538
+ end
539
+
540
+ [false, new_saved, action]
541
+ end
542
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
543
+
544
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
545
+ def self._apply_sgr(params, attrs)
546
+ if params.empty? || params == [0]
547
+ return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false,
548
+ blink: false,)
549
+ end
550
+
551
+ i = 0
552
+ while i < params.length
553
+ p = params[i]
554
+ case p
555
+ when 0
556
+ attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false)
557
+ when 1
558
+ attrs[:bold] = true
559
+ when 3
560
+ attrs[:italic] = true
561
+ when 4
562
+ attrs[:underline] = true
563
+ when 5, 6
564
+ attrs[:blink] = true
565
+ when 22
566
+ attrs[:bold] = false
567
+ when 23
568
+ attrs[:italic] = false
569
+ when 24
570
+ attrs[:underline] = false
571
+ when 25
572
+ attrs[:blink] = false
573
+ when 7, 27
574
+ # Reverse — swap fg and bg
575
+ attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
576
+ when 30..37
577
+ attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
578
+ when 38
579
+ # Extended foreground
580
+ if params[i + 1] == 5
581
+ color = params[i + 2]
582
+ attrs[:fg] = "color#{color}"
583
+ i += 2
584
+ # :nocov:
585
+ elsif params[i + 1] == 2
586
+ # :nocov:
587
+ r = params[i + 2]
588
+ g = params[i + 3]
589
+ b = params[i + 4]
590
+ attrs[:fg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
591
+ i += 4
592
+ end
593
+ when 39
594
+ attrs[:fg] = "default"
595
+ when 40..47
596
+ attrs[:bg] = SGR_16_TO_NAME[p - 40] || "bg_color#{p - 40}"
597
+ when 48
598
+ # Extended background
599
+ if params[i + 1] == 5
600
+ color = params[i + 2]
601
+ attrs[:bg] = "color#{color}"
602
+ i += 2
603
+ elsif params[i + 1] == 2
604
+ r = params[i + 2]
605
+ g = params[i + 3]
606
+ b = params[i + 4]
607
+ attrs[:bg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
608
+ i += 4
609
+ end
610
+ when 49
611
+ attrs[:bg] = "default"
612
+ when 90..97
613
+ attrs[:fg] = "bright_#{SGR_16_TO_NAME[p - 90] || "color#{p - 90 + 8}"}"
614
+ when 100..107
615
+ attrs[:bg] = "bright_#{SGR_16_TO_NAME[p - 100] || "color#{p - 100 + 8}"}"
616
+ end
617
+ i += 1
618
+ end
619
+ end
620
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
621
+
622
+ def self._erase_down(cursor, grid, rows, cols)
623
+ r = cursor[:row]
624
+ c = cursor[:col]
625
+
626
+ # Erase from cursor to end of line
627
+ (c...cols).each { |ci| _erase_cell(grid[r][ci]) if r < rows }
628
+
629
+ # Erase remaining lines
630
+ ((r + 1)...rows).each do |ri|
631
+ cols.times { |ci| _erase_cell(grid[ri][ci]) }
632
+ end
633
+ end
634
+
635
+ def self._erase_up(cursor, grid, cols)
636
+ r = cursor[:row]
637
+ c = cursor[:col]
638
+
639
+ # Erase lines above cursor
640
+ (0...r).each do |ri|
641
+ cols.times { |ci| _erase_cell(grid[ri][ci]) }
642
+ end
643
+
644
+ # Erase from start of line to cursor
645
+ (0..c).each { |ci| _erase_cell(grid[r][ci]) }
646
+ end
647
+
648
+ def self._erase_all(grid, rows, cols)
649
+ rows.times do |ri|
650
+ cols.times { |ci| _erase_cell(grid[ri][ci]) }
651
+ end
652
+ end
653
+
654
+ def self._erase_cell(cell)
655
+ cell.merge!(default_cell)
656
+ end
657
+
658
+ def self._erase_line_right(cursor, grid, cols)
659
+ r = cursor[:row]
660
+ c = cursor[:col]
661
+ (c...cols).each { |ci| _erase_cell(grid[r][ci]) if r < grid.length }
662
+ end
663
+
664
+ def self._erase_line_left(cursor, grid, _cols)
665
+ r = cursor[:row]
666
+ c = cursor[:col]
667
+ (0..c).each { |ci| _erase_cell(grid[r][ci]) if r < grid.length }
668
+ end
669
+
670
+ def self._erase_line(cursor, grid, cols)
671
+ r = cursor[:row]
672
+ cols.times { |ci| _erase_cell(grid[r][ci]) if r < grid.length }
673
+ end
674
+
675
+ # rubocop:disable Metrics/CyclomaticComplexity
676
+ def self._color_code(name, prefix)
677
+ case name
678
+ when "default" then nil
679
+ when /^#([0-9a-fA-F]{6})$/
680
+ r = ::Regexp.last_match(1)[0..1].to_i(16)
681
+ g = ::Regexp.last_match(1)[2..3].to_i(16)
682
+ b = ::Regexp.last_match(1)[4..5].to_i(16)
683
+ "#{prefix};2;#{r};#{g};#{b}"
684
+ when /^(bright_)?(.+)$/
685
+ base_name = ::Regexp.last_match(2)
686
+ index = SGR_16_TO_NAME.key(base_name)
687
+ index += 8 if ::Regexp.last_match(1) && index && index < 8
688
+ index ? "#{prefix};5;#{index}" : nil
689
+ end
690
+ end
691
+ # rubocop:enable Metrics/CyclomaticComplexity
692
+
693
+ def self.default_cell
694
+ { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
695
+ end
696
+
697
+ # Extract a single UTF-8 character at position i in a binary string.
698
+ # Returns [char_string, byte_length] or nil if the byte is not printable/valid.
699
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
700
+ def self._utf8_char_at(str, i)
701
+ byte = str.getbyte(i)
702
+ return nil unless byte
703
+
704
+ if byte < 0x80
705
+ # Single-byte ASCII
706
+ return nil unless byte >= 0x20 # only printable, skip control chars
707
+
708
+ return [byte.chr, 1]
709
+ end
710
+
711
+ # Multi-byte UTF-8
712
+ len = if byte & 0xE0 == 0xC0
713
+ 2
714
+ elsif byte & 0xF0 == 0xE0
715
+ 3
716
+ elsif byte & 0xF8 == 0xF0
717
+ 4
718
+ else
719
+ return nil # continuation byte or invalid — let main loop advance
720
+ end
721
+ return nil if i + len > str.bytesize
722
+
723
+ bytes = str.byteslice(i, len)
724
+ char = bytes.dup.force_encoding("UTF-8")
725
+ return nil unless char.valid_encoding?
726
+
727
+ [char, len]
728
+ # :nocov:
729
+ rescue StandardError
730
+ nil
731
+ # :nocov:
732
+ end
733
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
734
+ end
735
+ # rubocop:enable Metrics/ModuleLength
736
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TansParser
4
+ # Shared ANSI color constants and helpers.
5
+ # Used by Screenshot, HtmlRenderer, and other color-aware renderers.
6
+ module ANSIUtils
7
+ ANSI_RGB = {
8
+ "black" => [0x00, 0x00, 0x00],
9
+ "red" => [0xAA, 0x00, 0x00],
10
+ "green" => [0x00, 0xAA, 0x00],
11
+ "yellow" => [0xAA, 0x55, 0x00],
12
+ "blue" => [0x00, 0x00, 0xAA],
13
+ "magenta" => [0xAA, 0x00, 0xAA],
14
+ "cyan" => [0x00, 0xAA, 0xAA],
15
+ "white" => [0xAA, 0xAA, 0xAA],
16
+ "bright_black" => [0x55, 0x55, 0x55],
17
+ "bright_red" => [0xFF, 0x55, 0x55],
18
+ "bright_green" => [0x55, 0xFF, 0x55],
19
+ "bright_yellow" => [0xFF, 0xFF, 0x55],
20
+ "bright_blue" => [0x55, 0x55, 0xFF],
21
+ "bright_magenta" => [0xFF, 0x55, 0xFF],
22
+ "bright_cyan" => [0x55, 0xFF, 0xFF],
23
+ "bright_white" => [0xFF, 0xFF, 0xFF],
24
+ }.freeze
25
+
26
+ CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
27
+
28
+ ANSI_INDEX = %w[
29
+ black red green yellow blue magenta cyan white
30
+ bright_black bright_red bright_green bright_yellow
31
+ bright_blue bright_magenta bright_cyan bright_white
32
+ ].freeze
33
+
34
+ DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
35
+ DEFAULT_BG = [0x00, 0x00, 0x00].freeze
36
+
37
+ def resolve_color(name, fallback)
38
+ case name
39
+ when "default"
40
+ fallback
41
+ when /^#([0-9a-fA-F]{6})$/
42
+ [::Regexp.last_match(1)[0..1].to_i(16), ::Regexp.last_match(1)[2..3].to_i(16),
43
+ ::Regexp.last_match(1)[4..5].to_i(16),]
44
+ when /\Acolor(\d+)\z/
45
+ xterm_256(::Regexp.last_match(1).to_i)
46
+ when /\Abright_(.+)\z/
47
+ ANSI_RGB[name] || fallback
48
+ else # rubocop:disable Lint/DuplicateBranch
49
+ ANSI_RGB[name] || fallback
50
+ end
51
+ end
52
+
53
+ def xterm_256(index) # rubocop:disable Naming/VariableNumber
54
+ if index < 16
55
+ name = ANSI_INDEX[index]
56
+ ANSI_RGB[name] || DEFAULT_FG
57
+ elsif index < 232
58
+ r = CUBE[((index - 16) / 36) % 6]
59
+ g = CUBE[((index - 16) / 6) % 6]
60
+ b = CUBE[(index - 16) % 6]
61
+ [r, g, b]
62
+ else
63
+ v = 8 + ((index - 232) * 10)
64
+ [v, v, v]
65
+ end
66
+ end
67
+
68
+ def _dig(hash, *keys)
69
+ keys.each do |k|
70
+ return nil unless hash
71
+
72
+ hash = hash[k] || hash[k.to_s]
73
+ end
74
+ hash
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
4
+
5
+ require "timeout"
6
+
7
+ module TansParser
8
+ # Represents the parsed state of a terminal screen.
9
+ # Provides high-level query methods for AI consumption.
10
+ class State
11
+ attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
12
+
13
+ def initialize(data)
14
+ raise ArgumentError, "State data must include :size key" unless data[:size]
15
+ raise ArgumentError, "State data must include :rows key" unless data[:rows]
16
+
17
+ @rows = data[:size][:rows]
18
+ @cols = data[:size][:cols]
19
+ @grid = data[:rows]
20
+ @cursor = data[:cursor]
21
+
22
+ cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
23
+ @cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
24
+ @cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
25
+
26
+ @mouse_mode = data[:mouse_mode] || :none
27
+ @mouse_format = data[:mouse_format] || :normal
28
+ end
29
+
30
+ # Get plain text of the entire terminal (no ANSI)
31
+ def plain_text
32
+ @grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
33
+ end
34
+
35
+ # Get text at a specific position
36
+ def text_at(row, col, length = @cols - col)
37
+ return "" if row >= @rows || col >= @cols
38
+
39
+ @grid[row][col, length].map { |c| c[:char] }.join
40
+ end
41
+
42
+ # Search for text across the entire terminal.
43
+ # For regex patterns, matching is bounded by a timeout to prevent ReDoS.
44
+ TEXT_SEARCH_TIMEOUT = 5
45
+
46
+ def find_text(pattern)
47
+ results = []
48
+ is_regex = pattern.is_a?(Regexp)
49
+
50
+ @grid.each_with_index do |row, ri|
51
+ text = row.map { |c| c[:char] }.join
52
+ pos = 0
53
+ begin
54
+ if is_regex
55
+ Timeout.timeout(TEXT_SEARCH_TIMEOUT) do
56
+ while (match = text.index(pattern, pos))
57
+ results << { row: ri, col: match, text: pattern.to_s, full_line: text }
58
+ pos = match + 1
59
+ end
60
+ end
61
+ else
62
+ while (match = text.index(pattern, pos))
63
+ results << { row: ri, col: match, text: pattern, full_line: text }
64
+ pos = match + 1
65
+ end
66
+ end
67
+ rescue Timeout::Error
68
+ # Stop processing on timeout — return partial results
69
+ end
70
+ end
71
+ results
72
+ end
73
+
74
+ # Get the color at a specific cell
75
+ def foreground_at(row, col)
76
+ return nil if row >= @rows || col >= @cols
77
+
78
+ @grid[row][col][:fg]
79
+ end
80
+
81
+ def background_at(row, col)
82
+ return nil if row >= @rows || col >= @cols
83
+
84
+ @grid[row][col][:bg]
85
+ end
86
+
87
+ def style_at(row, col)
88
+ return nil if row >= @rows || col >= @cols
89
+
90
+ cell = @grid[row][col]
91
+ { bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
92
+ end
93
+
94
+ def to_ai_json
95
+ h = extract_highlights
96
+ cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
97
+ r = cursor_info[:row] || cursor_info["row"] || 0
98
+ c = cursor_info[:col] || cursor_info["col"] || 0
99
+ styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
100
+
101
+ summary = "Cursor at [#{r},#{c}]. "
102
+ summary << "#{styled_count} styled row#{"s" unless styled_count == 1}"
103
+ fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
104
+ bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
105
+ summary << ", colors: fg=#{fgs.sort.join(",")}" unless fgs.empty?
106
+ summary << ", bg=#{bgs.sort.join(",")}" unless bgs.empty?
107
+ summary << "."
108
+
109
+ {
110
+ size: { rows: @rows, cols: @cols },
111
+ cursor: cursor_info,
112
+ text: plain_text,
113
+ highlights: h,
114
+ summary: summary,
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ def extract_highlights
121
+ highlights = []
122
+ @grid.each_with_index do |row, ri|
123
+ row_text = row.map { |c| c[:char] }.join
124
+ next if row_text.strip.empty?
125
+
126
+ fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
127
+ .uniq.reject { |c| c == "default" }
128
+ bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
129
+ .uniq.reject { |c| c == "default" }
130
+ bold = row.any? { |c| c[:bold] || c["bold"] }
131
+ italic = row.any? { |c| c[:italic] || c["italic"] }
132
+ underline = row.any? { |c| c[:underline] || c["underline"] }
133
+
134
+ next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
135
+
136
+ h = { row: ri, text: row_text }
137
+ h[:bold] = true if bold
138
+ h[:italic] = true if italic
139
+ h[:underline] = true if underline
140
+ h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
141
+ h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
142
+ highlights << h
143
+ end
144
+ highlights
145
+ end
146
+ end
147
+ end
148
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TansParser
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tans-parser
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Haluk Durmus
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler-audit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pry
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.14'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.14'
40
+ - !ruby/object:Gem::Dependency
41
+ name: reek
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.3'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.12'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.12'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.50'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.50'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.7'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.7'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop-rspec
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.6'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '3.6'
110
+ - !ruby/object:Gem::Dependency
111
+ name: simplecov
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.22'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.22'
124
+ description: tans-parser parses raw terminal output with ANSI escape sequences into
125
+ a structured grid representation with per-cell attributes (char, fg, bg, bold, italic,
126
+ underline, blink). Includes a query API (State) for text search, color inspection,
127
+ and AI-friendly JSON output.
128
+ email:
129
+ - haluk_durmus@yahoo.de
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - CHANGELOG.md
135
+ - LICENSE.txt
136
+ - README.md
137
+ - lib/tans-parser.rb
138
+ - lib/tans_parser/ansi_parser.rb
139
+ - lib/tans_parser/ansi_utils.rb
140
+ - lib/tans_parser/state.rb
141
+ - lib/tans_parser/version.rb
142
+ homepage: https://github.com/vurte/tans-parser
143
+ licenses:
144
+ - MIT
145
+ metadata:
146
+ homepage_uri: https://github.com/vurte/tans-parser
147
+ source_code_uri: https://github.com/vurte/tans-parser
148
+ rubygems_mfa_required: 'true'
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: 3.0.0
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 4.0.6
164
+ specification_version: 4
165
+ summary: Terminal ANSI State Utils — parse ANSI terminal output into structured data
166
+ test_files: []