tans-parser 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +146 -23
- data/lib/tans-parser.rb +1 -0
- data/lib/tans_parser/element.rb +26 -0
- data/lib/tans_parser/scoped_selector.rb +119 -0
- data/lib/tans_parser/selector.rb +276 -37
- data/lib/tans_parser/state.rb +100 -24
- data/lib/tans_parser/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc3b07fbdd59e595af3d07d3091cdb75b9faa0c4daeaaaf3232f7787d35406e4
|
|
4
|
+
data.tar.gz: 75f354d01a6881cc8bb702bb900b41b2f654ecae8b7d5033bc439ad876eeb97a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d79356c65433991718c0c1854faa58f5a077a906ec653f13202d241b37fc01bb3fe253de8ba4fa9deb3b7bdee17965a1b2d2476a3b782c0a849c8b0696c4a65
|
|
7
|
+
data.tar.gz: 05045d158716d7e4ef9f87ecd2d4936735d1de681fb17cb97835a078b31654c9d0ed47022b610db7824d2a7408e82b66c34df738f1bc3b0ad7e67e40bfb4e111
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
- **Dialog recognition** — added support for rounded corners (`╭╮╰╯`) and double-line (`╔╗╚╝`) box-drawing characters
|
|
6
|
+
- `TOP_LEFT_CORNERS` extended with `╭╔╓╒`
|
|
7
|
+
- `dialog_top_width` extended with `╮╗╖╕` (top-right) and `═` (double horizontal)
|
|
8
|
+
- **Statusbar recognition** — more flexible detection:
|
|
9
|
+
- Now checks last 2 rows instead of only the last row
|
|
10
|
+
- Fallback: detects last row as statusbar if it has ≥30 characters of content, even without background color info
|
|
11
|
+
- Handles Karat-style footers (`? for shortcuts | mock ctx ░░░░░░░░░░ 0%`)
|
|
12
|
+
- **Custom role registration** — `State#annotate_role(role, row:, col:, width:, height:, text:, **extra)`:
|
|
13
|
+
- Manually annotate grid regions with semantic roles
|
|
14
|
+
- `Selector#detect_annotations` picks them up during `scan` alongside auto-detected elements
|
|
15
|
+
- Annotations support filters (text, checked, disabled) and all convenience methods
|
|
16
|
+
- **State#diff** — cell-level comparison between two State instances:
|
|
17
|
+
- `diff(other_state)` — compares all 7 cell keys (`char`, `fg`, `bg`, `bold`, `italic`, `underline`, `blink`)
|
|
18
|
+
- `diff(other_state, chars_only: true)` — compares only `:char`, ignores style/color changes
|
|
19
|
+
- Handles different grid sizes (fills missing cells with `DEFAULT_CELL`)
|
|
20
|
+
- Accepts raw hash or State object
|
|
21
|
+
- 19 new tests, 319 total, 100% line and branch coverage maintained
|
|
22
|
+
|
|
23
|
+
## 0.1.2
|
|
24
|
+
|
|
25
|
+
- **Flexible text search** — `State#find_text(pattern, match: :partial)` with three modes:
|
|
26
|
+
- `:partial` (default) — substring match (unchanged)
|
|
27
|
+
- `:exact` — row text must equal pattern (ignoring trailing whitespace)
|
|
28
|
+
- `:regex` — compile String to Regexp for matching
|
|
29
|
+
- Regexp objects now return the actual matched substring in `result[:text]`
|
|
30
|
+
- **Enhanced `get_by_role`** — filter keyword arguments:
|
|
31
|
+
- `text:` — partial match on element text
|
|
32
|
+
- `checked:` — filter by checked state (`true`/`false`)
|
|
33
|
+
- `disabled:` — filter by disabled state (`true`/`false`)
|
|
34
|
+
- All plural convenience methods (`buttons`, `checkboxes`, etc.) accept the same filters
|
|
35
|
+
- **New UI element roles**:
|
|
36
|
+
- `:input` — `[________]` underscore-filled brackets (text input fields)
|
|
37
|
+
- `:label` — `Word:` or `Multiple Words:` patterns
|
|
38
|
+
- `:menu` — menu bars (row 0–1, spaced words) and `> Item` dropdown items
|
|
39
|
+
- `:tab` — closely-spaced `[Tab1] [Tab2]` brackets, with `focused` detection via underline/background
|
|
40
|
+
- **Singular convenience methods** — return first matching Element or `nil`:
|
|
41
|
+
- `button`, `checkbox`, `input`, `dialog`, `label`, `menu`, `tab`, `statusbar`, `progress_bar`
|
|
42
|
+
- All accept the same filter kwargs (`text:`, `checked:`, `disabled:`)
|
|
43
|
+
- Existing plural methods (`buttons`, `checkboxes`, etc.) unchanged
|
|
44
|
+
- **Element actions** — `Element` now has action methods returning descriptive hashes:
|
|
45
|
+
- `click` → `{action: :click, target:, row:, col:}`
|
|
46
|
+
- `type(text)` → `{action: :type, target:, row:, col:, text:}`
|
|
47
|
+
- `press_key(key)` → `{action: :press_key, target:, key:}`
|
|
48
|
+
- **Element predicates** — `checked?` and `disabled?` (always return boolean)
|
|
49
|
+
- **Element `bounds`** — returns `{row:, col:, width:, height:}`
|
|
50
|
+
- **`disabled` field** — added to Element struct
|
|
51
|
+
- **Scoping (`within`)** — `Selector#within(element, &block)` with `TansParser::ScopedSelector`:
|
|
52
|
+
- `get_by_role`, `get_by_text`, `find_text` restricted to element's bounding box
|
|
53
|
+
- All convenience methods (singular + plural) available inside scope
|
|
54
|
+
- Works with or without block
|
|
55
|
+
- **Button detection** — now skips checkbox markers (`[x]`, `[X]`, `[*]`, `[ ]`) and underscore-only brackets
|
|
56
|
+
- 105 new tests, 100% line and branch coverage maintained (300 total)
|
|
57
|
+
|
|
3
58
|
## 0.1.1
|
|
4
59
|
|
|
5
60
|
- `TansParser::Element` — value object for recognized UI elements (role, text, position, size, colors)
|
data/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# tans-parser
|
|
2
2
|
|
|
3
3
|
Parse raw terminal output with ANSI escape sequences into structured, queryable data.
|
|
4
|
+
Recognizes UI elements heuristically for AI-driven terminal interaction.
|
|
4
5
|
|
|
5
6
|
## Installation
|
|
6
7
|
|
|
@@ -36,9 +37,12 @@ state = TansParser::State.new(state_data)
|
|
|
36
37
|
state.plain_text
|
|
37
38
|
# => "ERROR: Something went wrong\nOK: All good"
|
|
38
39
|
|
|
39
|
-
#
|
|
40
|
-
state.find_text("ERROR")
|
|
41
|
-
|
|
40
|
+
# Text search — three match modes
|
|
41
|
+
state.find_text("ERROR") # :partial (default) — substring
|
|
42
|
+
state.find_text("ERROR", match: :exact) # :exact — row text must equal
|
|
43
|
+
state.find_text("\\d+", match: :regex) # :regex — compile string to Regexp
|
|
44
|
+
state.find_text(/\d{3}/) # Regexp object also supported
|
|
45
|
+
# => [{row:, col:, text:, full_line:}, ...]
|
|
42
46
|
|
|
43
47
|
# Cell-level queries
|
|
44
48
|
state.foreground_at(0, 0) # => "red"
|
|
@@ -74,34 +78,153 @@ xterm_256(16) # => [0x00, 0x00, 0x00]
|
|
|
74
78
|
state = TansParser::State.new(state_data)
|
|
75
79
|
selector = TansParser::Selector.new(state)
|
|
76
80
|
|
|
77
|
-
# Find UI elements by role
|
|
78
|
-
selector.buttons
|
|
79
|
-
selector.checkboxes
|
|
80
|
-
selector.
|
|
81
|
-
selector.
|
|
82
|
-
selector.
|
|
81
|
+
# Find UI elements by role (plural — returns Array)
|
|
82
|
+
selector.buttons # [ OK ], (Cancel), <Submit>
|
|
83
|
+
selector.checkboxes # [x], [*], [ ] at line starts
|
|
84
|
+
selector.inputs # [________] underscore-filled brackets
|
|
85
|
+
selector.labels # Name: patterns (text followed by colon)
|
|
86
|
+
selector.menus # Menu bars (row 0–1) and > dropdown items
|
|
87
|
+
selector.tabs # Closely-spaced [Tab1] [Tab2] brackets
|
|
88
|
+
selector.dialogs # Box-drawing character regions (┌─┐│└┘)
|
|
89
|
+
selector.statusbars # Bottom row with non-default background
|
|
90
|
+
selector.progress_bars # [#### ], [====> ] patterns
|
|
91
|
+
|
|
92
|
+
# Singular convenience methods — return Element or nil
|
|
93
|
+
selector.button # first button
|
|
94
|
+
selector.checkbox(text: "Save") # first matching checkbox
|
|
95
|
+
selector.input # first input
|
|
96
|
+
selector.dialog # first dialog
|
|
97
|
+
selector.tab # first tab
|
|
98
|
+
# ... label, menu, statusbar, progress_bar
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Element filtering
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# get_by_role with optional filters
|
|
105
|
+
selector.get_by_role(:button, text: "OK") # text filter (partial match)
|
|
106
|
+
selector.get_by_role(:checkbox, checked: true) # checked state filter
|
|
107
|
+
selector.get_by_role(:button, disabled: false) # disabled state filter
|
|
108
|
+
|
|
109
|
+
# Combined filters
|
|
110
|
+
selector.get_by_role(:checkbox, checked: true, text: "auto-save")
|
|
111
|
+
|
|
112
|
+
# Plural methods also accept filters
|
|
113
|
+
selector.checkboxes(checked: false) # unchecked only
|
|
114
|
+
selector.buttons(text: "Save") # buttons with matching text
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Scoping (within)
|
|
83
118
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
Restrict searches to an element's bounding box:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
dialog = selector.dialog
|
|
123
|
+
|
|
124
|
+
# With block
|
|
125
|
+
selector.within(dialog) do |scope|
|
|
126
|
+
scope.buttons # only buttons inside the dialog
|
|
127
|
+
scope.find_text("OK")
|
|
128
|
+
scope.button # singular — first button inside dialog
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Without block — returns ScopedSelector
|
|
132
|
+
scoped = selector.within(dialog)
|
|
133
|
+
scoped.get_by_role(:button)
|
|
134
|
+
scoped.find_text("Retry", match: :exact)
|
|
87
135
|
```
|
|
88
136
|
|
|
89
|
-
|
|
137
|
+
### Custom role registration
|
|
138
|
+
|
|
139
|
+
When heuristic detection fails, annotate grid regions manually:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
state = TansParser::State.new(state_data)
|
|
143
|
+
|
|
144
|
+
# Annotate a dialog that heuristics didn't recognize
|
|
145
|
+
state.annotate_role(:dialog, row: 5, col: 20, width: 28, height: 5, text: "Help")
|
|
146
|
+
state.annotate_role(:statusbar, row: 24, col: 0, width: 80, height: 1)
|
|
147
|
+
|
|
148
|
+
# Selector picks up annotations alongside auto-detected elements
|
|
149
|
+
selector = TansParser::Selector.new(state)
|
|
150
|
+
selector.dialogs # => includes annotated dialog
|
|
151
|
+
selector.statusbars # => includes annotated statusbar
|
|
152
|
+
|
|
153
|
+
# Annotations accept extra attributes
|
|
154
|
+
state.annotate_role(:button, row: 0, col: 0, width: 6, height: 1,
|
|
155
|
+
text: "Submit", fg: "green", disabled: false)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### State comparison (diff)
|
|
159
|
+
|
|
160
|
+
Compare two terminal states cell-by-cell:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
before = TansParser::State.new(state_data)
|
|
164
|
+
# ... some action changes the screen ...
|
|
165
|
+
after = TansParser::State.new(new_state_data)
|
|
166
|
+
|
|
167
|
+
# Full diff — compares all cell keys
|
|
168
|
+
diff = before.diff(after)
|
|
169
|
+
# => [{row: 3, col: 2, before: {char: "T", fg: "default", ...},
|
|
170
|
+
# after: {char: "X", fg: "default", ...}}]
|
|
171
|
+
|
|
172
|
+
# Chars-only diff — ignores color/style changes
|
|
173
|
+
diff = before.diff(after, chars_only: true)
|
|
174
|
+
# Only reports actual character differences
|
|
175
|
+
|
|
176
|
+
# Accepts raw hash as argument
|
|
177
|
+
diff = before.diff({size: {rows: 5, cols: 10}, cursor: {...}, rows: [...]})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Element actions & attributes
|
|
181
|
+
|
|
182
|
+
Each `TansParser::Element` is a Struct with data and action methods:
|
|
90
183
|
|
|
91
184
|
```ruby
|
|
92
185
|
el = selector.buttons.first
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
el.
|
|
96
|
-
el.
|
|
97
|
-
el.
|
|
98
|
-
el.
|
|
99
|
-
el.
|
|
100
|
-
el.
|
|
101
|
-
el.
|
|
102
|
-
el.
|
|
186
|
+
|
|
187
|
+
# Data attributes
|
|
188
|
+
el.role # => :button
|
|
189
|
+
el.text # => "OK"
|
|
190
|
+
el.row # => 1
|
|
191
|
+
el.col # => 2
|
|
192
|
+
el.width # => 4
|
|
193
|
+
el.height # => 1
|
|
194
|
+
el.checked # => true/false/nil
|
|
195
|
+
el.focused # => true/false/nil
|
|
196
|
+
el.disabled # => true/false/nil
|
|
197
|
+
el.fg # => "default"
|
|
198
|
+
el.bg # => "default"
|
|
199
|
+
el.to_h # => {role: :button, text: "OK", row: 1, col: 2, ...}
|
|
200
|
+
|
|
201
|
+
# Predicates
|
|
202
|
+
el.checked? # => false (always boolean)
|
|
203
|
+
el.disabled? # => false (always boolean)
|
|
204
|
+
|
|
205
|
+
# Geometry
|
|
206
|
+
el.bounds # => {row: 1, col: 2, width: 4, height: 1}
|
|
207
|
+
|
|
208
|
+
# Actions — return descriptive hashes for AI consumption
|
|
209
|
+
el.click # => {action: :click, target: el, row: 1, col: 4}
|
|
210
|
+
el.type("hello") # => {action: :type, target: el, row: 1, col: 4, text: "hello"}
|
|
211
|
+
el.press_key(:tab) # => {action: :press_key, target: el, key: :tab}
|
|
103
212
|
```
|
|
104
213
|
|
|
214
|
+
### Recognized element patterns
|
|
215
|
+
|
|
216
|
+
| Role | Pattern | Example |
|
|
217
|
+
|------|---------|---------|
|
|
218
|
+
| `:button` | `[...]`, `(...)`, `<...>` | `[ OK ]`, `(Cancel)`, `<Submit>` |
|
|
219
|
+
| `:checkbox` | `[x]`, `[*]`, `[X]`, `[ ]` + label | `[x] Enable logging` |
|
|
220
|
+
| `:input` | `[_+]` inside brackets | `[________]` |
|
|
221
|
+
| `:label` | `Word:` or `Multiple Words:` | `Project Name:` |
|
|
222
|
+
| `:menu` | Menu bar (row 0–1, spaced words) or `> Item` | `File Edit Help`, `> New File` |
|
|
223
|
+
| `:tab` | ≥2 closely-spaced `[...]` on one row | `[Tab1] [Tab2] [Tab3]` |
|
|
224
|
+
| `:dialog` | Unicode box-drawing borders | `┌──┐` `│ │` `└──┘` |
|
|
225
|
+
| `:statusbar` | Last row with ≥3 non-default-bg cells | Inverse status line |
|
|
226
|
+
| `:progress` | `[###...]` with `#`, `>`, `=`, `-` fill | `[##### ] 50%` |
|
|
227
|
+
|
|
105
228
|
## Cell format
|
|
106
229
|
|
|
107
230
|
Each cell is a Hash with these keys:
|
data/lib/tans-parser.rb
CHANGED
data/lib/tans_parser/element.rb
CHANGED
|
@@ -10,8 +10,33 @@ module TansParser
|
|
|
10
10
|
:checked,
|
|
11
11
|
:focused,
|
|
12
12
|
:fg, :bg,
|
|
13
|
+
:disabled,
|
|
13
14
|
keyword_init: true,
|
|
14
15
|
) do
|
|
16
|
+
def checked?
|
|
17
|
+
!!checked
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def disabled?
|
|
21
|
+
!!disabled
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bounds
|
|
25
|
+
{ row: row, col: col, width: width, height: height }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def click
|
|
29
|
+
{ action: :click, target: self, row: row, col: col + (width / 2) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def type(text)
|
|
33
|
+
{ action: :type, target: self, row: row, col: col + (width / 2), text: text }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def press_key(key)
|
|
37
|
+
{ action: :press_key, target: self, key: key }
|
|
38
|
+
end
|
|
39
|
+
|
|
15
40
|
def to_h
|
|
16
41
|
{
|
|
17
42
|
role: role,
|
|
@@ -21,6 +46,7 @@ module TansParser
|
|
|
21
46
|
checked: checked,
|
|
22
47
|
focused: focused,
|
|
23
48
|
fg: fg, bg: bg,
|
|
49
|
+
disabled: disabled,
|
|
24
50
|
}.compact
|
|
25
51
|
end
|
|
26
52
|
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module TansParser
|
|
6
|
+
# Scoped view of a terminal screen restricted to an element's bounding box.
|
|
7
|
+
# Created by Selector#within(element).
|
|
8
|
+
#
|
|
9
|
+
# selector.within(dialog) do |scope|
|
|
10
|
+
# scope.buttons # only buttons inside the dialog
|
|
11
|
+
# scope.find_text("OK")
|
|
12
|
+
# end
|
|
13
|
+
class ScopedSelector
|
|
14
|
+
attr_reader :element
|
|
15
|
+
|
|
16
|
+
def initialize(selector, element)
|
|
17
|
+
@selector = selector
|
|
18
|
+
@element = element
|
|
19
|
+
@state = selector.state
|
|
20
|
+
@row_start = element.row
|
|
21
|
+
@row_end = element.row + element.height - 1
|
|
22
|
+
@col_start = element.col
|
|
23
|
+
@col_end = element.col + element.width - 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Find elements by role, scoped to the bounding box.
|
|
27
|
+
def get_by_role(role, text: nil, checked: nil, disabled: nil)
|
|
28
|
+
@selector.get_by_role(role, text: text, checked: checked, disabled: disabled)
|
|
29
|
+
.select { |e| fully_within?(e) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find elements by visible text, scoped to the bounding box.
|
|
33
|
+
def get_by_text(text)
|
|
34
|
+
@selector.get_by_text(text)
|
|
35
|
+
.select { |e| fully_within?(e) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Search for text, scoped to the bounding box.
|
|
39
|
+
def find_text(pattern, match: :partial)
|
|
40
|
+
results = []
|
|
41
|
+
grid = @state.grid
|
|
42
|
+
max_row = [@row_end, grid.length - 1].min
|
|
43
|
+
max_col = [@col_end, grid[0].length - 1, 0].max
|
|
44
|
+
|
|
45
|
+
(@row_start..max_row).each do |r|
|
|
46
|
+
row = grid[r]
|
|
47
|
+
slice = row[@col_start..max_col]
|
|
48
|
+
|
|
49
|
+
row_text = slice.map { |c| c[:char] }.join
|
|
50
|
+
find_in_row(pattern, match, r, row_text, results)
|
|
51
|
+
end
|
|
52
|
+
results
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Convenience plural accessors
|
|
56
|
+
def buttons(**filters) = get_by_role(:button, **filters)
|
|
57
|
+
def checkboxes(**filters) = get_by_role(:checkbox, **filters)
|
|
58
|
+
def dialogs(**filters) = get_by_role(:dialog, **filters)
|
|
59
|
+
def inputs(**filters) = get_by_role(:input, **filters)
|
|
60
|
+
def labels(**filters) = get_by_role(:label, **filters)
|
|
61
|
+
def menus(**filters) = get_by_role(:menu, **filters)
|
|
62
|
+
def tabs(**filters) = get_by_role(:tab, **filters)
|
|
63
|
+
def statusbars(**filters) = get_by_role(:statusbar, **filters)
|
|
64
|
+
def progress_bars(**filters) = get_by_role(:progress, **filters)
|
|
65
|
+
|
|
66
|
+
# Convenience singular accessors
|
|
67
|
+
def button(**filters) = buttons(**filters).first
|
|
68
|
+
def checkbox(**filters) = checkboxes(**filters).first
|
|
69
|
+
def dialog(**filters) = dialogs(**filters).first
|
|
70
|
+
def input(**filters) = inputs(**filters).first
|
|
71
|
+
def label(**filters) = labels(**filters).first
|
|
72
|
+
def menu(**filters) = menus(**filters).first
|
|
73
|
+
def tab(**filters) = tabs(**filters).first
|
|
74
|
+
def statusbar(**filters) = statusbars(**filters).first
|
|
75
|
+
def progress_bar(**filters) = progress_bars(**filters).first
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def fully_within?(elem)
|
|
80
|
+
elem.row.between?(@row_start, @row_end) &&
|
|
81
|
+
elem.col >= @col_start &&
|
|
82
|
+
elem.col <= @col_end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
86
|
+
def find_in_row(pattern, match, row_idx, row_text, results)
|
|
87
|
+
case match
|
|
88
|
+
when :partial
|
|
89
|
+
compiled = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern.to_s))
|
|
90
|
+
find_regex_in_text(compiled, row_idx, row_text, results)
|
|
91
|
+
when :exact
|
|
92
|
+
pattern_str = pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
|
|
93
|
+
return unless row_text.strip == pattern_str
|
|
94
|
+
|
|
95
|
+
results << { row: row_idx, col: @col_start, text: row_text.rstrip, full_line: row_text }
|
|
96
|
+
when :regex
|
|
97
|
+
compiled = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
98
|
+
find_regex_in_text(compiled, row_idx, row_text, results)
|
|
99
|
+
else
|
|
100
|
+
raise ArgumentError, "unknown match mode: #{match.inspect}. Use :partial, :exact, or :regex"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
104
|
+
|
|
105
|
+
def find_regex_in_text(compiled, row_idx, text, results)
|
|
106
|
+
pos = 0
|
|
107
|
+
begin
|
|
108
|
+
Timeout.timeout(State::TEXT_SEARCH_TIMEOUT) do
|
|
109
|
+
while (m = text.match(compiled, pos))
|
|
110
|
+
results << { row: row_idx, col: @col_start + m.begin(0), text: m[0], full_line: text }
|
|
111
|
+
pos = m.begin(0) + 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
rescue Timeout::Error
|
|
115
|
+
# Stop processing on timeout — return partial results
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/tans_parser/selector.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/ClassLength
|
|
4
|
+
|
|
3
5
|
module TansParser
|
|
4
6
|
# Scans terminal state for recognized UI elements.
|
|
5
7
|
#
|
|
@@ -8,9 +10,10 @@ module TansParser
|
|
|
8
10
|
# selector.get_by_role(:button) # => [Element, ...]
|
|
9
11
|
# selector.buttons # => [Element, ...]
|
|
10
12
|
# selector.dialogs # => [Element, ...]
|
|
13
|
+
# selector.button(text: "OK") # => Element or nil
|
|
11
14
|
#
|
|
12
15
|
class Selector
|
|
13
|
-
TOP_LEFT_CORNERS = /[
|
|
16
|
+
TOP_LEFT_CORNERS = /[┌┏┎┍╭╔╓╒]/
|
|
14
17
|
BOTTOM_LEFT_CORNERS = %w[└ ┗ ┖ ┕ ╰ ╚].freeze
|
|
15
18
|
BOTTOM_RIGHT_CORNERS = %w[┘ ┛ ┚ ┙ ╯ ╝].freeze
|
|
16
19
|
|
|
@@ -26,30 +29,99 @@ module TansParser
|
|
|
26
29
|
@elements.select { |e| e.text&.include?(text) }
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
# Find elements by role.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
# Find elements by role with optional filters.
|
|
33
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
34
|
+
def get_by_role(role, text: nil, checked: nil, disabled: nil)
|
|
35
|
+
results = @elements.select { |e| e.role == role.to_sym }
|
|
36
|
+
results = results.select { |e| e.text.to_s.include?(text.to_s) } if text
|
|
37
|
+
results = results.select { |e| e.checked == checked } unless checked.nil?
|
|
38
|
+
results = results.select { |e| e.disabled == disabled } unless disabled.nil?
|
|
39
|
+
results
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
42
|
+
|
|
43
|
+
# Convenience accessors (plural — return arrays)
|
|
44
|
+
def buttons(**filters)
|
|
45
|
+
get_by_role(:button, **filters)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def checkboxes(**filters)
|
|
49
|
+
get_by_role(:checkbox, **filters)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dialogs(**filters)
|
|
53
|
+
get_by_role(:dialog, **filters)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inputs(**filters)
|
|
57
|
+
get_by_role(:input, **filters)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def labels(**filters)
|
|
61
|
+
get_by_role(:label, **filters)
|
|
32
62
|
end
|
|
33
63
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
get_by_role(:button)
|
|
64
|
+
def menus(**filters)
|
|
65
|
+
get_by_role(:menu, **filters)
|
|
37
66
|
end
|
|
38
67
|
|
|
39
|
-
def
|
|
40
|
-
get_by_role(:
|
|
68
|
+
def tabs(**filters)
|
|
69
|
+
get_by_role(:tab, **filters)
|
|
41
70
|
end
|
|
42
71
|
|
|
43
|
-
def
|
|
44
|
-
get_by_role(:
|
|
72
|
+
def statusbars(**filters)
|
|
73
|
+
get_by_role(:statusbar, **filters)
|
|
45
74
|
end
|
|
46
75
|
|
|
47
|
-
def
|
|
48
|
-
get_by_role(:
|
|
76
|
+
def progress_bars(**filters)
|
|
77
|
+
get_by_role(:progress, **filters)
|
|
49
78
|
end
|
|
50
79
|
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
# Convenience accessors (singular — return first element or nil)
|
|
81
|
+
def button(**filters)
|
|
82
|
+
buttons(**filters).first
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def checkbox(**filters)
|
|
86
|
+
checkboxes(**filters).first
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dialog(**filters)
|
|
90
|
+
dialogs(**filters).first
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def input(**filters)
|
|
94
|
+
inputs(**filters).first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def label(**filters)
|
|
98
|
+
labels(**filters).first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def menu(**filters)
|
|
102
|
+
menus(**filters).first
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def tab(**filters)
|
|
106
|
+
tabs(**filters).first
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def statusbar(**filters)
|
|
110
|
+
statusbars(**filters).first
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def progress_bar(**filters)
|
|
114
|
+
progress_bars(**filters).first
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Scope subsequent searches to a specific element's bounding box.
|
|
118
|
+
def within(element, &block)
|
|
119
|
+
scoped = ScopedSelector.new(self, element)
|
|
120
|
+
if block
|
|
121
|
+
yield scoped
|
|
122
|
+
else
|
|
123
|
+
scoped
|
|
124
|
+
end
|
|
53
125
|
end
|
|
54
126
|
|
|
55
127
|
private
|
|
@@ -60,16 +132,27 @@ module TansParser
|
|
|
60
132
|
|
|
61
133
|
def scan
|
|
62
134
|
results = []
|
|
135
|
+
results.concat(detect_tabs)
|
|
136
|
+
results.concat(detect_inputs)
|
|
63
137
|
results.concat(detect_buttons)
|
|
64
138
|
results.concat(detect_checkboxes)
|
|
65
139
|
results.concat(detect_dialogs)
|
|
140
|
+
results.concat(detect_labels)
|
|
141
|
+
results.concat(detect_menus)
|
|
66
142
|
results.concat(detect_statusbars)
|
|
67
143
|
results.concat(detect_progress_bars)
|
|
144
|
+
results.concat(detect_annotations)
|
|
68
145
|
results
|
|
69
146
|
end
|
|
70
147
|
|
|
148
|
+
# Detects annotations: manually annotated roles from State#annotate_role
|
|
149
|
+
def detect_annotations
|
|
150
|
+
@state.annotations.map { |a| Element.new(a) }
|
|
151
|
+
end
|
|
152
|
+
|
|
71
153
|
# Detects buttons: [ OK ], (Cancel), <Submit>
|
|
72
|
-
#
|
|
154
|
+
# Skips underscore-only brackets (those are inputs).
|
|
155
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
73
156
|
def detect_buttons
|
|
74
157
|
buttons = []
|
|
75
158
|
grid.each_with_index do |row, r|
|
|
@@ -78,6 +161,8 @@ module TansParser
|
|
|
78
161
|
scan_match.each do
|
|
79
162
|
text = (::Regexp.last_match(1) || ::Regexp.last_match(2) || ::Regexp.last_match(3)).to_s.strip
|
|
80
163
|
next if text.empty?
|
|
164
|
+
next if text.match?(/^_+$/)
|
|
165
|
+
next if text.match?(/^[ xX*]$/) # skip checkbox markers
|
|
81
166
|
|
|
82
167
|
col = ::Regexp.last_match.begin(0)
|
|
83
168
|
buttons << Element.new(
|
|
@@ -92,7 +177,7 @@ module TansParser
|
|
|
92
177
|
end
|
|
93
178
|
buttons
|
|
94
179
|
end
|
|
95
|
-
# rubocop:enable Metrics/
|
|
180
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
96
181
|
|
|
97
182
|
# Detects checkboxes: [x], [*], [ ] at start of lines
|
|
98
183
|
def detect_checkboxes
|
|
@@ -103,13 +188,13 @@ module TansParser
|
|
|
103
188
|
next unless match
|
|
104
189
|
|
|
105
190
|
checked = match[2] != " "
|
|
106
|
-
|
|
191
|
+
label_text = match[3].strip
|
|
107
192
|
col = match.begin(3)
|
|
108
193
|
checkboxes << Element.new(
|
|
109
194
|
role: :checkbox,
|
|
110
|
-
text:
|
|
195
|
+
text: label_text,
|
|
111
196
|
row: r, col: col,
|
|
112
|
-
width:
|
|
197
|
+
width: label_text.length, height: 1,
|
|
113
198
|
checked: checked,
|
|
114
199
|
)
|
|
115
200
|
end
|
|
@@ -149,10 +234,13 @@ module TansParser
|
|
|
149
234
|
# Returns the width of a dialog top border if valid, nil otherwise
|
|
150
235
|
def dialog_top_width(line, tl_idx)
|
|
151
236
|
top_row = line[tl_idx..]
|
|
152
|
-
|
|
153
|
-
|
|
237
|
+
# Find first top-right corner anywhere after the top-left corner.
|
|
238
|
+
# Allows text/titles in the top border (e.g. ╭─ Commands ─╮).
|
|
239
|
+
tr_idx = top_row.index(/[┐┓┒┑╮╗╖╕]/)
|
|
240
|
+
return nil unless tr_idx
|
|
241
|
+
return nil if tr_idx < 2 # minimum dialog width (corner + at least 1 char + corner)
|
|
154
242
|
|
|
155
|
-
|
|
243
|
+
tr_idx + 1
|
|
156
244
|
end
|
|
157
245
|
|
|
158
246
|
# Returns the row index of a matching bottom border, nil if not found
|
|
@@ -182,29 +270,69 @@ module TansParser
|
|
|
182
270
|
lines.join(" ").strip
|
|
183
271
|
end
|
|
184
272
|
|
|
185
|
-
# Detects statusbar: bottom
|
|
273
|
+
# Detects statusbar: bottom rows with reversed/inverse colors,
|
|
274
|
+
# or last row with substantial content even without background info.
|
|
275
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
186
276
|
def detect_statusbars
|
|
187
277
|
bars = []
|
|
188
278
|
return bars if grid.empty?
|
|
189
279
|
|
|
190
|
-
|
|
191
|
-
|
|
280
|
+
# Check last 2 rows for non-default background
|
|
281
|
+
[grid.length - 1, grid.length - 2].uniq.each do |row_idx|
|
|
282
|
+
next if row_idx.negative?
|
|
283
|
+
|
|
284
|
+
row = grid[row_idx]
|
|
285
|
+
non_default = row.reject { |c| c[:bg] == "default" }
|
|
286
|
+
text = row.map { |c| c[:char] }.join.strip
|
|
287
|
+
next if non_default.length < 3 || text.empty?
|
|
192
288
|
|
|
193
|
-
|
|
194
|
-
|
|
289
|
+
bars << Element.new(
|
|
290
|
+
role: :statusbar, text: text,
|
|
291
|
+
row: row_idx, col: 0,
|
|
292
|
+
width: row.length, height: 1,
|
|
293
|
+
bg: non_default.first[:bg],
|
|
294
|
+
)
|
|
295
|
+
return bars
|
|
296
|
+
end
|
|
195
297
|
|
|
298
|
+
# Fallback: last row with substantial content (≥30 chars) but no bg info
|
|
299
|
+
last_row = grid[-1]
|
|
196
300
|
text = last_row.map { |c| c[:char] }.join.strip
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
301
|
+
if text.length >= 30
|
|
302
|
+
bars << Element.new(
|
|
303
|
+
role: :statusbar, text: text,
|
|
304
|
+
row: grid.length - 1, col: 0,
|
|
305
|
+
width: last_row.length, height: 1,
|
|
306
|
+
)
|
|
307
|
+
return bars
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Scan all rows for separator-preceded footers (Karat-style)
|
|
311
|
+
# Footer row follows a row of mostly ─/━/═ characters
|
|
312
|
+
grid.each_with_index do |row, r|
|
|
313
|
+
next if r.zero?
|
|
314
|
+
|
|
315
|
+
prev_chars = grid[r - 1].map { |c| c[:char] }.join
|
|
316
|
+
non_space = prev_chars.gsub(" ", "")
|
|
317
|
+
next if non_space.empty?
|
|
318
|
+
|
|
319
|
+
sep_ratio = non_space.count("─━═").to_f / non_space.length
|
|
320
|
+
next if sep_ratio < 0.8
|
|
321
|
+
|
|
322
|
+
text = row.map { |c| c[:char] }.join.strip
|
|
323
|
+
next if text.empty?
|
|
324
|
+
|
|
325
|
+
bars << Element.new(
|
|
326
|
+
role: :statusbar, text: text,
|
|
327
|
+
row: r, col: 0,
|
|
328
|
+
width: row.length, height: 1,
|
|
329
|
+
)
|
|
330
|
+
return bars
|
|
331
|
+
end
|
|
332
|
+
|
|
206
333
|
bars
|
|
207
334
|
end
|
|
335
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
208
336
|
|
|
209
337
|
# Detects progress bars: [#### ] or [=====> ] patterns
|
|
210
338
|
def detect_progress_bars
|
|
@@ -227,5 +355,116 @@ module TansParser
|
|
|
227
355
|
end
|
|
228
356
|
bars
|
|
229
357
|
end
|
|
358
|
+
|
|
359
|
+
# Detects text inputs: [____] underscore-filled brackets
|
|
360
|
+
def detect_inputs
|
|
361
|
+
inputs = []
|
|
362
|
+
grid.each_with_index do |row, r|
|
|
363
|
+
line = row.map { |c| c[:char] }.join
|
|
364
|
+
line.enum_for(:scan, /\[(_+)\]/).each do
|
|
365
|
+
m = ::Regexp.last_match
|
|
366
|
+
col = m.begin(0)
|
|
367
|
+
inputs << Element.new(
|
|
368
|
+
role: :input,
|
|
369
|
+
text: "",
|
|
370
|
+
row: r, col: col,
|
|
371
|
+
width: m[0].length, height: 1,
|
|
372
|
+
)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
inputs
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Detects labels: text followed by colon separator
|
|
379
|
+
def detect_labels
|
|
380
|
+
labels = []
|
|
381
|
+
grid.each_with_index do |row, r|
|
|
382
|
+
line = row.map { |c| c[:char] }.join
|
|
383
|
+
match = line.match(/\b([A-Za-z]\w*(?:\s+\w+)*\s*:)/)
|
|
384
|
+
next unless match
|
|
385
|
+
|
|
386
|
+
label_text = match[1].strip.sub(/:$/, "").strip
|
|
387
|
+
next if label_text.empty? || label_text.length < 2
|
|
388
|
+
|
|
389
|
+
col = match.begin(1)
|
|
390
|
+
labels << Element.new(
|
|
391
|
+
role: :label,
|
|
392
|
+
text: label_text,
|
|
393
|
+
row: r, col: col,
|
|
394
|
+
width: match[1].length, height: 1,
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
labels
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Detects menus: top-row menu bars and > dropdown items
|
|
401
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
402
|
+
def detect_menus
|
|
403
|
+
menus = []
|
|
404
|
+
grid.each_with_index do |row, r|
|
|
405
|
+
line = row.map { |c| c[:char] }.join
|
|
406
|
+
stripped = line.strip
|
|
407
|
+
next if stripped.empty?
|
|
408
|
+
|
|
409
|
+
# Menu bar on first two rows: words separated by 2+ spaces
|
|
410
|
+
if r <= 1
|
|
411
|
+
items = stripped.split(/\s{2,}/)
|
|
412
|
+
if items.length >= 2 && items.all? { |i| i.match?(/^[A-Za-z]/) }
|
|
413
|
+
col = line.index(stripped)
|
|
414
|
+
menus << Element.new(
|
|
415
|
+
role: :menu,
|
|
416
|
+
text: items.join(" | "),
|
|
417
|
+
row: r, col: col || 0,
|
|
418
|
+
width: line.length, height: 1,
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Dropdown item: > prefix
|
|
424
|
+
line.enum_for(:scan, /(>\s*[A-Za-z][\w\s]*)/).each do
|
|
425
|
+
m = ::Regexp.last_match
|
|
426
|
+
menus << Element.new(
|
|
427
|
+
role: :menu,
|
|
428
|
+
text: m[0].sub(/^>\s*/, "").strip,
|
|
429
|
+
row: r, col: m.begin(0),
|
|
430
|
+
width: m[0].length, height: 1,
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
menus
|
|
435
|
+
end
|
|
436
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
437
|
+
|
|
438
|
+
# Detects tabs: multiple closely-spaced [bracketed] items on one row
|
|
439
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
440
|
+
def detect_tabs
|
|
441
|
+
tabs = []
|
|
442
|
+
grid.each_with_index do |row, r|
|
|
443
|
+
line = row.map { |c| c[:char] }.join
|
|
444
|
+
matches = line.enum_for(:scan, /\[([^\]]+)\]/).map { ::Regexp.last_match }
|
|
445
|
+
next if matches.length < 2
|
|
446
|
+
|
|
447
|
+
gaps_close = matches.each_cons(2).all? { |a, b| b.begin(0) - a.end(0) <= 3 }
|
|
448
|
+
next unless gaps_close
|
|
449
|
+
|
|
450
|
+
matches.each do |m|
|
|
451
|
+
tab_text = m[1].strip
|
|
452
|
+
next if tab_text.empty? || tab_text.match?(/^_+$/)
|
|
453
|
+
|
|
454
|
+
cell = row[m.begin(0)]
|
|
455
|
+
focused = cell[:underline] || cell[:bg] != "default"
|
|
456
|
+
tabs << Element.new(
|
|
457
|
+
role: :tab,
|
|
458
|
+
text: tab_text,
|
|
459
|
+
row: r, col: m.begin(0),
|
|
460
|
+
width: m[0].length, height: 1,
|
|
461
|
+
focused: focused,
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
tabs
|
|
466
|
+
end
|
|
467
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
230
468
|
end
|
|
231
469
|
end
|
|
470
|
+
# rubocop:enable Metrics/AbcSize, Metrics/ClassLength
|
data/lib/tans_parser/state.rb
CHANGED
|
@@ -8,7 +8,7 @@ module TansParser
|
|
|
8
8
|
# Represents the parsed state of a terminal screen.
|
|
9
9
|
# Provides high-level query methods for AI consumption.
|
|
10
10
|
class State
|
|
11
|
-
attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
|
|
11
|
+
attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format, :annotations
|
|
12
12
|
|
|
13
13
|
def initialize(data)
|
|
14
14
|
raise ArgumentError, "State data must include :size key" unless data[:size]
|
|
@@ -25,7 +25,18 @@ module TansParser
|
|
|
25
25
|
|
|
26
26
|
@mouse_mode = data[:mouse_mode] || :none
|
|
27
27
|
@mouse_format = data[:mouse_format] || :normal
|
|
28
|
+
|
|
29
|
+
@annotations = data[:annotations] || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Annotate a region of the terminal with a semantic role.
|
|
33
|
+
# These annotations are picked up by Selector during element recognition.
|
|
34
|
+
# rubocop:disable Metrics/ParameterLists
|
|
35
|
+
def annotate_role(role, row:, col:, width: 1, height: 1, text: nil, **extra)
|
|
36
|
+
@annotations << { role: role.to_sym, row: row, col: col,
|
|
37
|
+
width: width, height: height, text: text, }.merge(extra)
|
|
28
38
|
end
|
|
39
|
+
# rubocop:enable Metrics/ParameterLists
|
|
29
40
|
|
|
30
41
|
# Get plain text of the entire terminal (no ANSI)
|
|
31
42
|
def plain_text
|
|
@@ -41,32 +52,29 @@ module TansParser
|
|
|
41
52
|
|
|
42
53
|
# Search for text across the entire terminal.
|
|
43
54
|
# For regex patterns, matching is bounded by a timeout to prevent ReDoS.
|
|
55
|
+
#
|
|
56
|
+
# state.find_text("hello") # partial match (default)
|
|
57
|
+
# state.find_text("hello", match: :exact) # exact row match
|
|
58
|
+
# state.find_text("\\d+", match: :regex) # regex from string
|
|
59
|
+
# state.find_text(/\\d{3}/) # Regexp object (partial mode)
|
|
60
|
+
#
|
|
61
|
+
# Returns [{ row:, col:, text:, full_line: }, ...].
|
|
62
|
+
# +text+ is the actual matched substring (for Regexp/:regex mode)
|
|
63
|
+
# or the pattern string (for :partial/:exact with String).
|
|
44
64
|
TEXT_SEARCH_TIMEOUT = 5
|
|
45
65
|
|
|
46
|
-
def find_text(pattern)
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
def find_text(pattern, match: :partial)
|
|
67
|
+
unless %i[partial exact regex].include?(match)
|
|
68
|
+
raise ArgumentError, "unknown match mode: #{match.inspect}. Use :partial, :exact, or :regex"
|
|
69
|
+
end
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
71
|
+
results = []
|
|
72
|
+
case match
|
|
73
|
+
when :exact
|
|
74
|
+
find_text_exact(pattern, results)
|
|
75
|
+
else
|
|
76
|
+
compiled = compile_pattern(pattern, match)
|
|
77
|
+
find_text_with_regex(compiled, results)
|
|
70
78
|
end
|
|
71
79
|
results
|
|
72
80
|
end
|
|
@@ -115,6 +123,32 @@ module TansParser
|
|
|
115
123
|
}
|
|
116
124
|
end
|
|
117
125
|
|
|
126
|
+
DEFAULT_CELL = { char: " ", fg: "default", bg: "default",
|
|
127
|
+
bold: false, italic: false, underline: false, blink: false, }.freeze
|
|
128
|
+
|
|
129
|
+
# Compare this state with another State and return cell-level differences.
|
|
130
|
+
# With chars_only: true, only differences in the :char key are reported.
|
|
131
|
+
# Use ignore_rows: to skip specific rows (e.g. cursor/prompt lines).
|
|
132
|
+
def diff(other_state, chars_only: false, ignore_rows: [])
|
|
133
|
+
other = other_state.is_a?(State) ? other_state : State.new(other_state)
|
|
134
|
+
max_rows = [@rows, other.rows].max
|
|
135
|
+
max_cols = [@cols, other.cols].max
|
|
136
|
+
results = []
|
|
137
|
+
|
|
138
|
+
(0...max_rows).each do |r|
|
|
139
|
+
next if ignore_rows.include?(r)
|
|
140
|
+
|
|
141
|
+
(0...max_cols).each do |c|
|
|
142
|
+
a = cell_at(r, c)
|
|
143
|
+
b = other.send(:cell_at, r, c)
|
|
144
|
+
next if chars_only ? a[:char] == b[:char] : a == b
|
|
145
|
+
|
|
146
|
+
results << { row: r, col: c, before: a, after: b }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
results
|
|
150
|
+
end
|
|
151
|
+
|
|
118
152
|
private
|
|
119
153
|
|
|
120
154
|
def extract_highlights
|
|
@@ -143,6 +177,48 @@ module TansParser
|
|
|
143
177
|
end
|
|
144
178
|
highlights
|
|
145
179
|
end
|
|
180
|
+
|
|
181
|
+
def compile_pattern(pattern, match)
|
|
182
|
+
return pattern if pattern.is_a?(Regexp)
|
|
183
|
+
|
|
184
|
+
source = match == :regex ? pattern.to_s : Regexp.escape(pattern.to_s)
|
|
185
|
+
Regexp.new(source)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def find_text_exact(pattern, results)
|
|
189
|
+
pattern_str = pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
|
|
190
|
+
@grid.each_with_index do |row, ri|
|
|
191
|
+
row_text = row.map { |c| c[:char] }.join.rstrip
|
|
192
|
+
next unless row_text == pattern_str
|
|
193
|
+
|
|
194
|
+
results << { row: ri, col: 0, text: row_text, full_line: row_text }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def find_text_with_regex(compiled, results)
|
|
199
|
+
@grid.each_with_index do |row, ri|
|
|
200
|
+
text = row.map { |c| c[:char] }.join
|
|
201
|
+
pos = 0
|
|
202
|
+
begin
|
|
203
|
+
Timeout.timeout(TEXT_SEARCH_TIMEOUT) do
|
|
204
|
+
while (m = text.match(compiled, pos))
|
|
205
|
+
results << { row: ri, col: m.begin(0), text: m[0], full_line: text }
|
|
206
|
+
pos = m.begin(0) + 1
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
rescue Timeout::Error
|
|
210
|
+
# Stop processing on timeout — return partial results
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def cell_at(row, col)
|
|
216
|
+
if row < @rows && col < @cols
|
|
217
|
+
@grid[row][col]
|
|
218
|
+
else
|
|
219
|
+
DEFAULT_CELL
|
|
220
|
+
end
|
|
221
|
+
end
|
|
146
222
|
end
|
|
147
223
|
end
|
|
148
224
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
data/lib/tans_parser/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tans-parser
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Haluk Durmus
|
|
@@ -139,6 +139,7 @@ files:
|
|
|
139
139
|
- lib/tans_parser/ansi_parser.rb
|
|
140
140
|
- lib/tans_parser/ansi_utils.rb
|
|
141
141
|
- lib/tans_parser/element.rb
|
|
142
|
+
- lib/tans_parser/scoped_selector.rb
|
|
142
143
|
- lib/tans_parser/selector.rb
|
|
143
144
|
- lib/tans_parser/state.rb
|
|
144
145
|
- lib/tans_parser/version.rb
|