irb-autosuggestions 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02ad5c3a9b0166ac04a5ace310b88b4608ada72fa88d3d0a4f563817042db007
4
- data.tar.gz: b68cd6b388fcdfb423ee895ed1758fdd4f8f878cc8c65faf4e45bfc8ffb7ccab
3
+ metadata.gz: 48eb1b9edb63b8d65e26cb9917c5631ec58ccac72e05ab0c47efa7c2521e84c0
4
+ data.tar.gz: 7369bbf6293a3ff1c99f733b5026d3d0d6a68305af4330af8ebe5feff8944041
5
5
  SHA512:
6
- metadata.gz: a16cf68316c28e28aa74993736e78eca09ad0f96fda3e0afb5acfc7c9d1a77ab7804abcc820e7e83055982677f3f2631a276ca3f1a536522b25db912a43a7b55
7
- data.tar.gz: bbbc34eaf686fa8c425f83da67c337c06cae44584e2675419b2d8faee4f94859cc8edc3c4ca51f6535bc0496a8e353f744c0ef0c469941978072e04046feacd8
6
+ metadata.gz: d362044adf712df1f2f36a7305554c837263001d557fe6d999614f51cfd43901b336402326c3008d20c2191c89c60fb54618e79dd785626ceb0abd11eb7d43f4
7
+ data.tar.gz: d7aee55ff2cd6ec1c49c0b4adeeb890010cda94ffe0b354acb7d63c50081bb6c74c79f8553f820564b592c2ca47cdf8239a492ac1f1a97754be60190ab641635
data/.rubocop.yml CHANGED
@@ -5,7 +5,7 @@ plugins:
5
5
 
6
6
  AllCops:
7
7
  NewCops: enable
8
- TargetRubyVersion: 3.0
8
+ TargetRubyVersion: 2.7
9
9
 
10
10
  Style/DoubleNegation:
11
11
  Enabled: false
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.0.7
1
+ 2.7.8
data/Gemfile CHANGED
@@ -2,5 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- # Specify your gem's dependencies in irb-autosuggestions.gemspec
6
5
  gemspec
6
+
7
+ gem 'rbs', '~> 3.6.0', require: false if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0')
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- irb-autosuggestions (0.1.1)
4
+ irb-autosuggestions (0.2.0)
5
5
  reline
6
6
 
7
7
  GEM
@@ -70,9 +70,10 @@ GEM
70
70
  unicode-display_width (3.2.0)
71
71
  unicode-emoji (~> 4.1)
72
72
  unicode-emoji (4.2.0)
73
- yard (0.9.43)
73
+ yard (0.9.44)
74
74
 
75
75
  PLATFORMS
76
+ arm64-darwin-24
76
77
  arm64-darwin-25
77
78
  x86_64-linux
78
79
 
@@ -80,7 +81,7 @@ DEPENDENCIES
80
81
  docscribe
81
82
  irb-autosuggestions!
82
83
  rake
83
- rbs
84
+ rbs (~> 3.6.0)
84
85
  rspec (~> 3.0)
85
86
  rubocop
86
87
  rubocop-rake
@@ -89,4 +90,4 @@ DEPENDENCIES
89
90
  yard (>= 0.9.38)
90
91
 
91
92
  BUNDLED WITH
92
- 2.5.23
93
+ 4.0.12
data/Gemfile_2_7 ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in irb-autosuggestions.gemspec
6
+ gemspec
data/Gemfile_2_7.lock ADDED
@@ -0,0 +1,88 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ irb-autosuggestions (0.2.0)
5
+ reline
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ ast (2.4.3)
11
+ diff-lcs (1.6.2)
12
+ docscribe (1.3.3)
13
+ parser (>= 3.3)
14
+ prism (~> 1.8)
15
+ io-console (0.8.2)
16
+ json (2.19.5)
17
+ language_server-protocol (3.17.0.5)
18
+ lint_roller (1.1.0)
19
+ parallel (1.28.0)
20
+ parser (3.3.11.1)
21
+ ast (~> 2.4.1)
22
+ racc
23
+ prism (1.9.0)
24
+ racc (1.8.1)
25
+ rainbow (3.1.1)
26
+ rake (13.4.2)
27
+ regexp_parser (2.12.0)
28
+ reline (0.6.3)
29
+ io-console (~> 0.5)
30
+ rspec (3.13.2)
31
+ rspec-core (~> 3.13.0)
32
+ rspec-expectations (~> 3.13.0)
33
+ rspec-mocks (~> 3.13.0)
34
+ rspec-core (3.13.6)
35
+ rspec-support (~> 3.13.0)
36
+ rspec-expectations (3.13.5)
37
+ diff-lcs (>= 1.2.0, < 2.0)
38
+ rspec-support (~> 3.13.0)
39
+ rspec-mocks (3.13.8)
40
+ diff-lcs (>= 1.2.0, < 2.0)
41
+ rspec-support (~> 3.13.0)
42
+ rspec-support (3.13.7)
43
+ rubocop (1.86.2)
44
+ json (~> 2.3)
45
+ language_server-protocol (~> 3.17.0.2)
46
+ lint_roller (~> 1.1.0)
47
+ parallel (>= 1.10)
48
+ parser (>= 3.3.0.2)
49
+ rainbow (>= 2.2.2, < 4.0)
50
+ regexp_parser (>= 2.9.3, < 3.0)
51
+ rubocop-ast (>= 1.49.0, < 2.0)
52
+ ruby-progressbar (~> 1.7)
53
+ unicode-display_width (>= 2.4.0, < 4.0)
54
+ rubocop-ast (1.49.1)
55
+ parser (>= 3.3.7.2)
56
+ prism (~> 1.7)
57
+ rubocop-rake (0.7.1)
58
+ lint_roller (~> 1.1)
59
+ rubocop (>= 1.72.1)
60
+ rubocop-rspec (3.9.0)
61
+ lint_roller (~> 1.1)
62
+ rubocop (~> 1.81)
63
+ rubocop-sorted_methods_by_call (1.2.2)
64
+ lint_roller
65
+ rubocop (>= 1.72.0)
66
+ ruby-progressbar (1.13.0)
67
+ unicode-display_width (3.2.0)
68
+ unicode-emoji (~> 4.1)
69
+ unicode-emoji (4.2.0)
70
+ yard (0.9.44)
71
+
72
+ PLATFORMS
73
+ x86_64-linux
74
+ arm64-darwin-25
75
+
76
+ DEPENDENCIES
77
+ docscribe
78
+ irb-autosuggestions!
79
+ rake
80
+ rspec (~> 3.0)
81
+ rubocop
82
+ rubocop-rake
83
+ rubocop-rspec
84
+ rubocop-sorted_methods_by_call
85
+ yard (>= 0.9.38)
86
+
87
+ BUNDLED WITH
88
+ 2.4.22
data/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # Irb::Autosuggestions
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/irb-autosuggestions.svg)](https://rubygems.org/gems/irb-autosuggestions)
4
+ [![RubyGems Downloads](https://img.shields.io/gem/dt/irb-autosuggestions.svg)](https://rubygems.org/gems/irb-autosuggestions)
4
5
  [![CI](https://github.com/unurgunite/irb-autosuggestions/actions/workflows/ci.yml/badge.svg)](https://github.com/unurgunite/irb-autosuggestions/actions)
6
+ [![License](https://img.shields.io/github/license/unurgunite/irb-autosuggestions.svg)](https://github.com/unurgunite/irb-autosuggestions/blob/master/LICENSE.txt)
7
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-blue.svg)](#installation)
5
8
 
6
9
  ![Irb::Autosuggestions](readme.png)
7
10
 
@@ -13,7 +16,9 @@ No need to explain. Fish-like autosuggestions for IRB — ghost text from histor
13
16
  * [Contents](#contents)
14
17
  * [Installation](#installation)
15
18
  * [Usage](#usage)
19
+ * [Prefix-filtered history navigation](#prefix-filtered-history-navigation)
16
20
  * [Configuration](#configuration)
21
+ * [Colors](#colors)
17
22
  * [How it works](#how-it-works)
18
23
  * [Development](#development)
19
24
  * [License](#license)
@@ -34,7 +39,8 @@ require 'irb-autosuggestions'
34
39
 
35
40
  ## Usage
36
41
 
37
- Start typing in IRB. Gray ghost text appears after the cursor, showing the most recent matching history entry:
42
+ Start typing in IRB. Ghost text appears after the cursor, showing the most recent matching history entry with syntax
43
+ coloring (or gray if colorization is unavailable or disabled):
38
44
 
39
45
  ```
40
46
  irb(main):001* [1,2,3].map do |el|
@@ -44,6 +50,36 @@ irb(main):003> end <- "d" in gray
44
50
 
45
51
  Press **right arrow** (`->`) to accept the full multiline suggestion.
46
52
 
53
+ ### Prefix-filtered history navigation
54
+
55
+ **Up/down arrows** navigate history filtered by the typed prefix (like zsh). Start typing and press **up** — only
56
+ entries matching your prefix are shown. The prefix is frozen on the first press; subsequent presses keep searching
57
+ within that prefix:
58
+
59
+ ```
60
+ irb(main):001:0> def <- type "def", press up
61
+ irb(main):001:0> def foo <- press up again then older "def*" entry
62
+ ```
63
+
64
+ Press **right arrow** to accept the suggestion and exit prefix mode. Any non-history key (letter, enter, etc.) resets
65
+ the prefix anchor, returning arrows to normal unfiltered browsing.
66
+
67
+ Consecutive duplicate entries are collapsed during prefix search so each unique line appears once.
68
+
69
+ Regular unfiltered history browsing (empty buffer + up arrow) is unchanged — all entries are shown including duplicates.
70
+
71
+ To disable prefix navigation while keeping ghost text:
72
+
73
+ ```ruby
74
+ IRB.conf[:USE_PREFIX_HISTORY_NAVIGATION] = false
75
+ ```
76
+
77
+ Or via environment variable:
78
+
79
+ ```sh
80
+ export IRB_PREFIX_HISTORY_NAVIGATION=0
81
+ ```
82
+
47
83
  ## Configuration
48
84
 
49
85
  Autosuggestions are enabled by default. To disable:
@@ -60,13 +96,39 @@ Or via environment variable:
60
96
  export IRB_AUTOSUGGESTIONS=0
61
97
  ```
62
98
 
99
+ ### Colors
100
+
101
+ Syntax-colored ghost text is **enabled by default** when `IRB::Color` is available and `IRB.conf[:USE_COLORIZE]` is
102
+ true.
103
+
104
+ To disable colored ghost text (falls back to plain gray):
105
+
106
+ ```ruby
107
+ IRB.conf[:USE_COLORIZE] = false
108
+ ```
109
+
110
+ Or from command line:
111
+
112
+ ```sh
113
+ irb --nocolorize
114
+ ```
115
+
116
+ > [!NOTE] Colorized ghost rendering may behave differently across terminal emulators, Ruby versions, and IRB color
117
+ > schemes. If you notice visual artifacts (e.g., wrong colors, underlines, or unusual brightness), try disabling the
118
+ > feature or switch to the gray fallback or create new issue.
119
+
63
120
  ### How it works
64
121
 
65
122
  - Each keystroke queries `Reline::HISTORY` for the most recent entry whose prefix matches the current buffer.
66
- - The suggestion is rendered inline as gray (`\e[90m`) text without modifying the buffer.
123
+ - The suggestion is rendered inline as ghost text without modifying the buffer.
124
+ - When available, the ghost uses `IRB::Color.colorize_code` to match IRB's syntax colors, dimmed via ANSI escape codes
125
+ for visual distinction.
67
126
  - Extra ghost lines (for multiline history entries) are drawn below the prompt.
68
- - `\e[J` clears stale ghost artifacts from the viewport.
127
+ - Ghost artifacts are cleared each frame using cursor save/restore (`\e[s`/`\e[u`) and per-line clearing (`\e[2K`),
128
+ avoiding `\e[J` which interfered with Reline's cursor tracking.
69
129
  - Right arrow triggers `ed_next_char`, which replaces the buffer with the ghost text.
130
+ - Up/down arrows use prefix-filtered navigation when the buffer is non-empty, falling back to unfiltered browsing
131
+ when nothing was typed.
70
132
 
71
133
  ## Development
72
134
 
@@ -3,17 +3,26 @@
3
3
  module Irb
4
4
  module Autosuggestions
5
5
  # Patches Reline::LineEditor to display fish-like autosuggestions from history.
6
+ # rubocop:disable Metrics/ModuleLength, SortedMethodsByCall/Waterfall
6
7
  module LineEditorPatch
7
8
  GRAY = "\e[90m"
9
+ DIM = "\e[2m"
10
+ RESET_COLOR = "\e[39;49m"
8
11
  RESET = "\e[0m"
12
+ FG_COLORS = ((30..37).to_a + (90..97).to_a + [38, 39]).freeze
9
13
  CONFIG_KEY = :USE_AUTOSUGGESTIONS
10
14
  ENV_KEY = 'IRB_AUTOSUGGESTIONS'
15
+ CONFIG_NAV_KEY = :USE_PREFIX_HISTORY_NAVIGATION
16
+ ENV_NAV_KEY = 'IRB_PREFIX_HISTORY_NAVIGATION'
11
17
 
12
- # Intercepts key input to accept autosuggestions on right arrow.
18
+ # Intercepts key input to accept autosuggestions on right arrow
19
+ # and clears the prefix navigation anchor on non-history keys.
13
20
  #
14
21
  # @param [Object] key A Reline key event.
15
- # @return [Object] Returns +super+ for non-right-arrow keys, +nil+ after accept.
22
+ # @return [Object]
16
23
  def input_key(key)
24
+ clear_prefix_anchor if navigation_enabled? && !history_navigation_key?(key)
25
+
17
26
  if enabled? && right_arrow?(key)
18
27
  buffer = whole_buffer
19
28
  suggestion = find_suggestion(buffer)
@@ -30,22 +39,149 @@ module Irb
30
39
  private
31
40
 
32
41
  # Injects ghost text into terminal output after Reline finishes rendering.
42
+ # Clears any previous ghost text (inline and multi-line) first,
43
+ # then renders the new ghost suggestion.
33
44
  #
34
45
  # @private
35
46
  # @return [Object] The result of +super+.
36
47
  def render(...)
37
48
  result = super
38
- Reline.core.instance_variable_get(:@output).write("\e[J")
39
- return result unless enabled?
49
+ if enabled?
50
+ clear_previous_ghost
51
+ render_ghost_suggestion
52
+ end
53
+ result
54
+ end
40
55
 
41
- buffer = whole_buffer
42
- return result if buffer.empty?
56
+ # Prefix-filtered up-arrow history navigation.
57
+ #
58
+ # @private
59
+ # @param [Object] key
60
+ # @param [Integer] arg Repeat count.
61
+ # @return [Object]
62
+ def ed_prev_history(key, arg: 1)
63
+ if navigation_enabled? && (@line_index.zero? || @history_pointer)
64
+ buffer = prefix_buffer_for_nav
65
+ if buffer && !buffer.empty?
66
+ walk_history_back(buffer, arg)
67
+ return
68
+ end
69
+ end
70
+ clear_prefix_anchor
71
+ super
72
+ end
43
73
 
44
- ghost = ghost_for(buffer)
45
- return result unless ghost
74
+ # Prefix-filtered down-arrow history navigation.
75
+ #
76
+ # @private
77
+ # @param [Object] key
78
+ # @param [Integer] arg Repeat count.
79
+ # @return [Object]
80
+ def ed_next_history(key, arg: 1)
81
+ if navigation_enabled? && @history_pointer && @prefix_buffer
82
+ walk_history_forward(arg)
83
+ return
84
+ end
85
+ clear_prefix_anchor
86
+ super
87
+ end
46
88
 
47
- render_ghost(ghost)
48
- result
89
+ # Returns the anchor buffer for prefix navigation: the frozen prefix
90
+ # during an ongoing session, +whole_buffer+ on the first press from
91
+ # base buffer, or +nil+ when nothing was typed.
92
+ #
93
+ # @private
94
+ # @return [String, nil]
95
+ def prefix_buffer_for_nav
96
+ return @prefix_buffer if @prefix_buffer
97
+
98
+ whole_buffer unless @history_pointer
99
+ end
100
+
101
+ # Checks if a key event triggers history navigation (up/down arrow).
102
+ #
103
+ # @private
104
+ # @param [Object] key A Reline key event.
105
+ # @return [Boolean]
106
+ def history_navigation_key?(key)
107
+ key.respond_to?(:method_symbol) &&
108
+ %i[ed_prev_history ed_next_history].include?(key.method_symbol)
109
+ end
110
+
111
+ # Clears ghost from the previous frame: inline text from buffer end
112
+ # to end of line, and each extra line below. Uses cursor save/restore
113
+ # so it does not interfere with Reline's cursor tracking.
114
+ #
115
+ # @private
116
+ # @return [void]
117
+ def clear_previous_ghost
118
+ clear_inline_ghost
119
+ return unless @ghost_line_count&.positive?
120
+
121
+ output = Reline.core.instance_variable_get(:@output)
122
+ output.write("\e[s")
123
+ @ghost_line_count.times { output.write("\e[1B\e[2K") }
124
+ output.write("\e[u")
125
+ end
126
+
127
+ # Clears inline ghost text from the buffer end to end of line.
128
+ #
129
+ # @private
130
+ # @return [void]
131
+ def clear_inline_ghost
132
+ return unless @has_inline_ghost
133
+
134
+ output = Reline.core.instance_variable_get(:@output)
135
+ prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
136
+ current_line = @buffer_of_lines[@line_index] || ''
137
+ buf_end = prompt_width + Reline::Unicode.calculate_width(current_line)
138
+ output.write("\e[s")
139
+ output.write("\e[0G\e[#{buf_end}C\e[K")
140
+ output.write("\e[u")
141
+ @has_inline_ghost = false
142
+ end
143
+
144
+ # Walks backward through history, loading each matching entry.
145
+ #
146
+ # @private
147
+ # @param [String] buffer The prefix anchor.
148
+ # @param [Integer] arg Repeat count.
149
+ # @return [Object]
150
+ def walk_history_back(buffer, arg)
151
+ arg.times do
152
+ pointer = find_prev_match(buffer, @history_pointer)
153
+ break unless pointer
154
+
155
+ move_history(pointer, line: :end, cursor: :end)
156
+ @prefix_buffer = buffer
157
+ end
158
+ end
159
+
160
+ # Walks forward through history. Returns to base when exhausted.
161
+ #
162
+ # @private
163
+ # @param [Integer] arg Repeat count.
164
+ # @return [Object]
165
+ def walk_history_forward(arg)
166
+ arg.times do
167
+ pointer = find_next_match(@prefix_buffer, @history_pointer)
168
+ if pointer
169
+ move_history(pointer, line: :start, cursor: :end)
170
+ else
171
+ move_history(Reline::HISTORY.size, line: :start, cursor: :end)
172
+ break
173
+ end
174
+ end
175
+ end
176
+
177
+ # Removes the prefix anchor so the next up/down starts fresh.
178
+ #
179
+ # @private
180
+ # @return [void]
181
+ def clear_prefix_anchor
182
+ return unless instance_variable_defined?(:@prefix_buffer)
183
+
184
+ remove_instance_variable(:@prefix_buffer)
49
185
  end
50
186
 
51
187
  # Checks whether autosuggestions are enabled via IRB.conf or env var.
@@ -56,7 +192,24 @@ module Irb
56
192
  case ENV.fetch(ENV_KEY, nil)
57
193
  when '0' then false
58
194
  when '1' then true
59
- else IRB.conf.fetch(CONFIG_KEY, true)
195
+ else
196
+ val = IRB.conf[CONFIG_KEY]
197
+ val.nil? || val
198
+ end
199
+ end
200
+
201
+ # Whether prefix-filtered history navigation is enabled.
202
+ # Falls back to the value of +enabled?+ when not explicitly set.
203
+ #
204
+ # @private
205
+ # @return [Boolean]
206
+ def navigation_enabled?
207
+ case ENV.fetch(ENV_NAV_KEY, nil)
208
+ when '0' then false
209
+ when '1' then true
210
+ else
211
+ val = IRB.conf[CONFIG_NAV_KEY]
212
+ val.nil? ? enabled? : val
60
213
  end
61
214
  end
62
215
 
@@ -70,41 +223,225 @@ module Irb
70
223
  key.method_symbol == :ed_next_char
71
224
  end
72
225
 
73
- # Computes the ghost text for a given buffer by finding the matching history entry.
226
+ # Finds the index of the previous (older) history entry starting with +buffer+.
74
227
  #
75
228
  # @private
76
- # @param [String] buffer The current whole buffer.
77
- # @return [String, nil] The remaining text of the suggestion, or nil.
78
- def ghost_for(buffer)
229
+ # @param [String] buffer Search prefix.
230
+ # @param [Integer, nil] from_pointer Current history pointer (nil = base buffer).
231
+ # @return [Integer, nil]
232
+ def find_prev_match(buffer, from_pointer)
233
+ start_idx = (from_pointer || Reline::HISTORY.size) - 1
234
+ return nil if start_idx.negative?
235
+
236
+ start_idx.downto(0) do |i|
237
+ entry = Reline::HISTORY[i]
238
+ next if entry.nil? || (dedup?(buffer) && duplicate_of_newer?(i, entry))
239
+
240
+ return i if entry.start_with?(buffer)
241
+ end
242
+ nil
243
+ end
244
+
245
+ # Finds the index of the next (newer) history entry starting with +buffer+.
246
+ #
247
+ # @private
248
+ # @param [String] buffer Search prefix.
249
+ # @param [Integer, nil] from_pointer Current history pointer.
250
+ # @return [Integer, nil]
251
+ def find_next_match(buffer, from_pointer) # rubocop:disable Metrics/CyclomaticComplexity
252
+ return nil unless from_pointer
253
+
254
+ start_idx = from_pointer + 1
255
+ return nil if start_idx > Reline::HISTORY.size - 1
256
+
257
+ (start_idx...Reline::HISTORY.size).each do |i|
258
+ entry = Reline::HISTORY[i]
259
+ next if entry.nil? || (dedup?(buffer) && duplicate_of_newer?(i, entry))
260
+
261
+ return i if entry.start_with?(buffer)
262
+ end
263
+ nil
264
+ end
265
+
266
+ # Whether duplicate collapsing is active (only for prefix search).
267
+ #
268
+ # @private
269
+ # @param [String] buffer Search prefix.
270
+ # @return [Boolean]
271
+ def dedup?(buffer)
272
+ !buffer.empty?
273
+ end
274
+
275
+ # Whether +entry+ at index +idx+ is a consecutive duplicate of the next entry.
276
+ #
277
+ # @private
278
+ # @param [Integer] idx
279
+ # @param [String] entry
280
+ # @return [Boolean]
281
+ def duplicate_of_newer?(idx, entry)
282
+ idx < Reline::HISTORY.size - 1 && entry == Reline::HISTORY[idx + 1]
283
+ end
284
+
285
+ # Renders ghost text for the current buffer, if a suggestion exists.
286
+ #
287
+ # @private
288
+ # @return [void]
289
+ def render_ghost_suggestion
290
+ buffer = whole_buffer
291
+ @ghost_line_count = 0
292
+ return if buffer.empty?
293
+
79
294
  suggestion = find_suggestion(buffer)
80
295
  return unless suggestion
81
296
 
82
297
  ghost = suggestion[buffer.size..]
83
298
  return if ghost.nil? || ghost.empty?
84
299
 
85
- ghost
300
+ render_ghost(ghost, suggestion)
301
+ end
302
+
303
+ # Finds the most recent history entry that starts with the given buffer.
304
+ #
305
+ # @private
306
+ # @param [String] buffer The current whole buffer.
307
+ # @return [String, nil] The matching history entry, or nil.
308
+ def find_suggestion(buffer)
309
+ Reline::HISTORY.reverse.find do |h|
310
+ h != buffer && h.start_with?(buffer)
311
+ end
312
+ end
313
+
314
+ # Replaces the entire buffer with the accepted suggestion and triggers a rerender.
315
+ #
316
+ # @private
317
+ # @param [String] suggestion The full multiline suggestion to accept.
318
+ # @return [void]
319
+ def accept_suggestion(suggestion)
320
+ sug_lines = suggestion.split("\n")
321
+ @buffer_of_lines = sug_lines
322
+ @line_index = sug_lines.size - 1
323
+ @byte_pointer = sug_lines.last.bytesize
324
+ rerender
86
325
  end
87
326
 
88
327
  # Writes the ghost text (inline + extra lines) to terminal output.
89
328
  #
329
+ # If +suggestion+ is provided and colorization is enabled, the ghost
330
+ # is rendered with syntax highlighting via IRB::Color.
331
+ #
90
332
  # @private
91
- # @param [String] ghost The full ghost text (may contain newlines).
333
+ # @param [String] ghost The ghost text (suffix of the suggestion).
334
+ # @param [String, nil] suggestion The full matching history entry.
92
335
  # @return [void]
93
- def render_ghost(ghost)
94
- lines = ghost.split("\n")
336
+ def render_ghost(ghost, suggestion = nil)
337
+ output = Reline.core.instance_variable_get(:@output)
338
+ display_lines = ghost_display_lines(ghost, suggestion)
339
+ @ghost_line_count = display_lines.size - 1
340
+ @has_inline_ghost = true
341
+
342
+ first_line = display_lines.first
343
+ output.write(first_line) if first_line && !first_line.empty?
344
+ write_extra_ghost_lines(display_lines.drop(1))
345
+ restore_cursor_after(display_lines)
346
+ output.flush
347
+ end
348
+
349
+ # Returns ghost lines ready for terminal output (with ANSI codes).
350
+ #
351
+ # When colorization is enabled, the full suggestion is colorized via
352
+ # IRB::Color and the ghost portion is extracted from the colored output.
353
+ # Otherwise, each line is wrapped in GRAY/RESET.
354
+ #
355
+ # @private
356
+ # @param [String] ghost The ghost text (suffix of the suggestion).
357
+ # @param [String, nil] suggestion The full matching history entry.
358
+ # @raise [StandardError]
359
+ # @return [Array<String>]
360
+ def ghost_display_lines(ghost, suggestion)
361
+ if suggestion && use_colorize?
362
+ colorize_ghost_lines(ghost, suggestion)
363
+ else
364
+ ghost.split("\n").map { |line| "#{GRAY}#{line}#{RESET}" }
365
+ end
366
+ rescue StandardError
367
+ ghost.split("\n").map { |line| "#{GRAY}#{line}#{RESET}" }
368
+ end
95
369
 
96
- Reline.core.instance_variable_get(:@output).write("#{GRAY}#{lines.first}#{RESET}") unless lines.first.empty?
370
+ # Checks whether syntax coloring is available and enabled.
371
+ #
372
+ # @private
373
+ # @return [Boolean]
374
+ def use_colorize?
375
+ defined?(IRB::Color) &&
376
+ IRB::Color.colorable? &&
377
+ IRB.conf.fetch(:USE_COLORIZE, true)
378
+ end
379
+
380
+ # Colorizes the full suggestion and extracts the ghost portion.
381
+ #
382
+ # @private
383
+ # @param [String] ghost The ghost text (suffix of the suggestion).
384
+ # @param [String] suggestion The full matching history entry.
385
+ # @return [Array<String>] Colorized ghost lines with ANSI codes.
386
+ def colorize_ghost_lines(ghost, suggestion)
387
+ colored = IRB::Color.colorize_code(suggestion)
388
+ ghost_byte_start = suggestion.bytesize - ghost.bytesize
389
+ colored_ghost = extract_ansi_colored_suffix(colored, ghost_byte_start)
390
+ colored_ghost.split("\n").map { |line| dim_line(line) }
391
+ end
392
+
393
+ # Prepends each ANSI foreground color code with +2;+ (dim)
394
+ # and strips non-color attributes (bold, underline, reverse…).
395
+ # Inner full resets are replaced with +RESET_COLOR+ so dim
396
+ # stays active across token boundaries.
397
+ #
398
+ # @private
399
+ # @param [String] line ANSI-colored line.
400
+ # @return [String] Dimmed ANSI-colored line.
401
+ def dim_line(line)
402
+ inner = line.gsub(/\e\[(\d+(?:;\d+)*)m/) do
403
+ params = Regexp.last_match(1)
404
+ next RESET_COLOR if params == '0'
97
405
 
98
- write_extra_ghost_lines(lines.drop(1))
99
- restore_cursor_after(lines)
406
+ color = params.split(';').map(&:to_i).select { |p| FG_COLORS.include?(p) }
407
+ color.empty? ? '' : "\e[2;#{color.join(';')}m"
408
+ end
409
+ "#{DIM}#{inner}#{RESET}"
410
+ end
411
+
412
+ # Extracts the suffix of an ANSI-colored string starting at a given
413
+ # visible byte offset, preserving all ANSI codes.
414
+ #
415
+ # @private
416
+ # @param [String] colored_text Text with embedded ANSI escape sequences.
417
+ # @param [Integer] visible_byte_offset Offset in visible (non-ANSI) bytes.
418
+ # @return [String]
419
+ def extract_ansi_colored_suffix(colored_text, visible_byte_offset) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
420
+ pos = 0
421
+ visible = 0
422
+ pending_code = nil
423
+
424
+ while visible < visible_byte_offset && pos < colored_text.length
425
+ if colored_text[pos] == "\e"
426
+ code_start = pos
427
+ pos = colored_text.index('m', pos)&.succ || colored_text.length
428
+ code = colored_text[code_start...pos]
100
429
 
101
- Reline.core.instance_variable_get(:@output).flush
430
+ pending_code = [RESET, "\e[m"].include?(code) ? nil : code
431
+ else
432
+ visible += 1
433
+ pos += 1
434
+ end
435
+ end
436
+
437
+ suffix = colored_text[pos..] || String.new
438
+ pending_code ? "#{pending_code}#{suffix}" : suffix
102
439
  end
103
440
 
104
441
  # Writes extra ghost lines below the current buffer line with prompt-width alignment.
105
442
  #
106
443
  # @private
107
- # @param [Array<String>] lines Extra ghost lines (excluding the first inline line).
444
+ # @param [Array<String>] lines Extra lines with ANSI codes (excluding first inline line).
108
445
  # @return [void]
109
446
  def write_extra_ghost_lines(lines)
110
447
  return if lines.empty?
@@ -115,7 +452,7 @@ module Irb
115
452
  lines.each do |line|
116
453
  output.write("\n\e[K")
117
454
  output.write("\e[#{prompt_width}C") if prompt_width.positive?
118
- output.write("#{GRAY}#{line}#{RESET}")
455
+ output.write(line)
119
456
  end
120
457
  end
121
458
 
@@ -134,30 +471,7 @@ module Irb
134
471
  output.write("\e[0G")
135
472
  output.write("\e[#{pos}C")
136
473
  end
137
-
138
- # Finds the most recent history entry that starts with the given buffer.
139
- #
140
- # @private
141
- # @param [String] buffer The current whole buffer.
142
- # @return [String, nil] The matching history entry, or nil.
143
- def find_suggestion(buffer)
144
- Reline::HISTORY.reverse.find do |h|
145
- h != buffer && h.start_with?(buffer)
146
- end
147
- end
148
-
149
- # Replaces the entire buffer with the accepted suggestion and triggers a rerender.
150
- #
151
- # @private
152
- # @param [String] suggestion The full multiline suggestion to accept.
153
- # @return [void]
154
- def accept_suggestion(suggestion)
155
- sug_lines = suggestion.split("\n")
156
- @buffer_of_lines = sug_lines
157
- @line_index = sug_lines.size - 1
158
- @byte_pointer = sug_lines.last.bytesize
159
- rerender
160
- end
161
474
  end
162
475
  end
163
476
  end
477
+ # rubocop:enable Metrics/ModuleLength, SortedMethodsByCall/Waterfall
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Irb
4
4
  module Autosuggestions
5
- VERSION = '0.1.1'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -93,10 +93,6 @@ gems:
93
93
  version: 4.0.2
94
94
  source:
95
95
  type: rubygems
96
- - name: rdoc
97
- version: '0'
98
- source:
99
- type: stdlib
100
96
  - name: regexp_parser
101
97
  version: '2.8'
102
98
  source:
@@ -1,10 +1,15 @@
1
1
  module Irb
2
2
  module Autosuggestions
3
3
  module LineEditorPatch
4
+ CONFIG_NAV_KEY: Symbol
5
+ DIM: String
6
+ ENV_NAV_KEY: String
7
+ FG_COLORS: Array[Integer]
4
8
  GRAY: String
5
9
  RESET: String
6
10
  CONFIG_KEY: Symbol
7
11
  ENV_KEY: String
12
+ RESET_COLOR: String
8
13
 
9
14
  @buffer_of_lines: Array[String]
10
15
  @byte_pointer: Integer
@@ -13,9 +18,12 @@ module Irb
13
18
  def input_key: (untyped key) -> untyped
14
19
  def render: (*untyped) -> untyped
15
20
  def enabled?: () -> bool
21
+ def use_colorize?: () -> bool
16
22
  def right_arrow?: (untyped key) -> bool
17
- def ghost_for: (String buffer) -> (String | nil)
18
- def render_ghost: (String ghost) -> void
23
+ def render_ghost: (String ghost, ?String suggestion) -> void
24
+ def ghost_display_lines: (String ghost, ?String suggestion) -> Array[String]
25
+ def colorize_ghost_lines: (String ghost, String suggestion) -> Array[String]
26
+ def extract_ansi_colored_suffix: (String colored_text, Integer visible_byte_offset) -> String
19
27
  def write_extra_ghost_lines: (Array[String] lines) -> void
20
28
  def restore_cursor_after: (Array[String] lines) -> void
21
29
  def find_suggestion: (String buffer) -> (String | nil)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: irb-autosuggestions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - unurgunite
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-23 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: reline
@@ -52,20 +52,6 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: rbs
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
55
  - !ruby/object:Gem::Dependency
70
56
  name: rspec
71
57
  requirement: !ruby/object:Gem::Requirement
@@ -162,6 +148,8 @@ files:
162
148
  - CODE_OF_CONDUCT.md
163
149
  - Gemfile
164
150
  - Gemfile.lock
151
+ - Gemfile_2_7
152
+ - Gemfile_2_7.lock
165
153
  - LICENSE.txt
166
154
  - README.md
167
155
  - Rakefile
@@ -191,14 +179,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
191
179
  requirements:
192
180
  - - ">="
193
181
  - !ruby/object:Gem::Version
194
- version: '3.0'
182
+ version: '2.7'
195
183
  required_rubygems_version: !ruby/object:Gem::Requirement
196
184
  requirements:
197
185
  - - ">="
198
186
  - !ruby/object:Gem::Version
199
187
  version: '0'
200
188
  requirements: []
201
- rubygems_version: 3.5.23
189
+ rubygems_version: 3.4.22
202
190
  signing_key:
203
191
  specification_version: 4
204
192
  summary: Fish-like autosuggestions for irb.