Module: Irb::Autosuggestions::LineEditorPatch

Defined in:
lib/irb/autosuggestions/line_editor_patch.rb,
sig/lib/irb/autosuggestions/line_editor_patch.rbs

Overview

Patches Reline::LineEditor to display fish-like autosuggestions from history. rubocop:disable Metrics/ModuleLength, SortedMethodsByCall/Waterfall

Constant Summary collapse

GRAY =

Returns:

  • (String)
"\e[90m"
DIM =

Returns:

  • (String)
"\e[2m"
RESET_COLOR =

Returns:

  • (String)
"\e[39;49m"
RESET =

Returns:

  • (String)
"\e[0m"
FG_COLORS =

Returns:

  • (Array[Integer])
((30..37).to_a + (90..97).to_a + [38, 39]).freeze
CONFIG_KEY =

Returns:

  • (Symbol)
:USE_AUTOSUGGESTIONS
ENV_KEY =

Returns:

  • (String)
'IRB_AUTOSUGGESTIONS'
CONFIG_NAV_KEY =

Returns:

  • (Symbol)
:USE_PREFIX_HISTORY_NAVIGATION
ENV_NAV_KEY =

Returns:

  • (String)
'IRB_PREFIX_HISTORY_NAVIGATION'
ACCEPT_KEYS_CONFIG =

Returns:

  • (Symbol)
:AUTOSUGGESTION_ACCEPT_KEYS
DEFAULT_ACCEPT_KEYS =

Returns:

  • (Array[Symbol])
%i[ed_next_char ed_end_of_line tab].freeze
GHOST_COLOR_CONFIG =

Returns:

  • (Symbol)
:AUTOSUGGESTION_GHOST_COLOR
GHOST_STYLE_CONFIG =

Returns:

  • (Symbol)
:AUTOSUGGESTION_GHOST_STYLE
DEFAULT_GHOST_COLOR =

Returns:

  • (String)
"\e[90m"
FG_MAP =

Returns:

  • (Hash[Symbol, Integer])
{
  black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37,
  default: 39,
  bright_black: 90, bright_red: 91, bright_green: 92, bright_yellow: 93,
  bright_blue: 94, bright_magenta: 95, bright_cyan: 96, bright_white: 97
}.freeze
ATTR_MAP =

Returns:

  • (Hash[Symbol, Integer])
{
  bold: 1, dim: 2, italic: 3, underline: 4, blink: 5, reverse: 7
}.freeze

Instance Method Summary collapse

Instance Method Details

#accept_key?(key) ⇒ Boolean

Checks if a key event matches any configured accept key. Defaults to right arrow (:ed_next_char). Tab is matched specially — method_symbol == :ed_insert + char == "\t".

Parameters:

  • key (Object)

    A Reline key event.

Returns:

  • (Boolean)


240
241
242
243
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 240

def accept_key?(key)
  accept_keys = IRB.conf.fetch(ACCEPT_KEYS_CONFIG, DEFAULT_ACCEPT_KEYS)
  accept_keys.any? { |k| key_match?(key, k) }
end

#accept_suggestion(suggestion) ⇒ void

This method returns an undefined value.

Replaces the entire buffer with the accepted suggestion and triggers a rerender.

Parameters:

  • suggestion (String)

    The full multiline suggestion to accept.



356
357
358
359
360
361
362
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 356

def accept_suggestion(suggestion)
  sug_lines = suggestion.split("\n")
  @buffer_of_lines = sug_lines
  @line_index = sug_lines.size - 1
  @byte_pointer = sug_lines.last.bytesize
  rerender
end

#colorize_ghost_lines(ghost, suggestion) ⇒ Array<String>

Colorizes the full suggestion and extracts the ghost portion.

Parameters:

  • ghost (String)

    The ghost text (suffix of the suggestion).

  • suggestion (String)

    The full matching history entry.

Returns:

  • (Array<String>)

    Colorized ghost lines with ANSI codes.



452
453
454
455
456
457
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 452

def colorize_ghost_lines(ghost, suggestion)
  colored = IRB::Color.colorize_code(suggestion)
  ghost_byte_start = suggestion.bytesize - ghost.bytesize
  colored_ghost = extract_ansi_colored_suffix(colored, ghost_byte_start)
  colored_ghost.split("\n").map { |line| dim_line(line) }
end

#dim_line(line) ⇒ String

Prepends each ANSI foreground color code with 2; (dim) and strips non-color attributes (bold, underline, reverse…). Inner full resets are replaced with RESET_COLOR so dim stays active across token boundaries.

Parameters:

  • line (String)

    ANSI-colored line.

Returns:

  • (String)

    Dimmed ANSI-colored line.



467
468
469
470
471
472
473
474
475
476
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 467

def dim_line(line)
  inner = line.gsub(/\e\[(\d+(?:;\d+)*)m/) do
    params = Regexp.last_match(1)
    next RESET_COLOR if params == '0'

    color = params.split(';').map(&:to_i).select { |p| FG_COLORS.include?(p) }
    color.empty? ? '' : "\e[2;#{color.join(';')}m"
  end
  "#{DIM}#{inner}#{RESET}"
end

#enabled?Boolean

Checks whether autosuggestions are enabled via IRB.conf or env var.

Returns:

  • (Boolean)


223
224
225
226
227
228
229
230
231
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 223

def enabled?
  case ENV.fetch(ENV_KEY, nil)
  when '0' then false
  when '1' then true
  else
    val = IRB.conf[CONFIG_KEY]
    val.nil? || val
  end
end

#extract_ansi_colored_suffix(colored_text, visible_byte_offset) ⇒ String

Extracts the suffix of an ANSI-colored string starting at a given visible byte offset, preserving all ANSI codes.

Parameters:

  • colored_text (String)

    Text with embedded ANSI escape sequences.

  • visible_byte_offset (Integer)

    Offset in visible (non-ANSI) bytes.

Returns:

  • (String)


485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 485

def extract_ansi_colored_suffix(colored_text, visible_byte_offset) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
  pos = 0
  visible = 0
  pending_code = nil

  while visible < visible_byte_offset && pos < colored_text.length
    if colored_text[pos] == "\e"
      code_start = pos
      pos = colored_text.index('m', pos)&.succ || colored_text.length
      code = colored_text[code_start...pos]

      pending_code = [RESET, "\e[m"].include?(code) ? nil : code
    else
      visible += 1
      pos += 1
    end
  end

  suffix = colored_text[pos..] || String.new
  pending_code ? "#{pending_code}#{suffix}" : suffix
end

#find_next_match(buffer, from_pointer) ⇒ Integer?

Finds the index of the next (newer) history entry starting with buffer.

Parameters:

  • buffer (String)

    Search prefix.

  • from_pointer (Integer, nil)

    Current history pointer.

Returns:

  • (Integer, nil)


288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 288

def find_next_match(buffer, from_pointer) # rubocop:disable Metrics/CyclomaticComplexity
  return nil unless from_pointer

  start_idx = from_pointer + 1
  return nil if start_idx > Reline::HISTORY.size - 1

  (start_idx...Reline::HISTORY.size).each do |i|
    entry = Reline::HISTORY[i]
    next if entry.nil? || (dedup?(buffer) && duplicate_of_newer?(i, entry))

    return i if entry.start_with?(buffer)
  end
  nil
end

#find_prev_match(buffer, from_pointer) ⇒ Integer?

Finds the index of the previous (older) history entry starting with buffer.

Parameters:

  • buffer (String)

    Search prefix.

  • from_pointer (Integer, nil)

    Current history pointer (nil = base buffer).

Returns:

  • (Integer, nil)


269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 269

def find_prev_match(buffer, from_pointer)
  start_idx = (from_pointer || Reline::HISTORY.size) - 1
  return nil if start_idx.negative?

  start_idx.downto(0) do |i|
    entry = Reline::HISTORY[i]
    next if entry.nil? || (dedup?(buffer) && duplicate_of_newer?(i, entry))

    return i if entry.start_with?(buffer)
  end
  nil
end

#find_suggestion(buffer) ⇒ String?

Finds the most recent history entry that starts with the given buffer.

Parameters:

  • buffer (String)

    The current whole buffer.

Returns:

  • (String, nil)

    The matching history entry, or nil.



345
346
347
348
349
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 345

def find_suggestion(buffer)
  Reline::HISTORY.reverse.find do |h|
    h != buffer && h.start_with?(buffer)
  end
end

#ghost_colorString

Returns the effective ANSI color code for ghost text. Checks GHOST_STYLE_CONFIG first, then GHOST_COLOR_CONFIG, then default.

Returns:

  • (String)


416
417
418
419
420
421
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 416

def ghost_color
  style = IRB.conf[GHOST_STYLE_CONFIG]
  return resolve_ghost_style(style) if style.is_a?(Hash)

  IRB.conf.fetch(GHOST_COLOR_CONFIG, DEFAULT_GHOST_COLOR)
end

#ghost_display_lines(ghost, suggestion) ⇒ Array<String>

Returns ghost lines ready for terminal output (with ANSI codes).

When colorization is enabled, the full suggestion is colorized via IRB::Color and the ghost portion is extracted from the colored output.

Parameters:

  • ghost (String)

    The ghost text (suffix of the suggestion).

  • suggestion (String, nil)

    The full matching history entry.

Returns:

  • (Array<String>)

Raises:

  • (StandardError)


401
402
403
404
405
406
407
408
409
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 401

def ghost_display_lines(ghost, suggestion)
  if suggestion && use_colorize?
    colorize_ghost_lines(ghost, suggestion)
  else
    ghost.split("\n").map { |line| "#{ghost_color}#{line}#{RESET}" }
  end
rescue StandardError
  ghost.split("\n").map { |line| "#{ghost_color}#{line}#{RESET}" }
end

#input_key(key) ⇒ Object

Intercepts key input to accept autosuggestions on configured keys and clears the prefix navigation anchor on non-history keys.

Parameters:

  • key (Object)

    A Reline key event.

Returns:

  • (Object)


37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 37

def input_key(key)
  clear_prefix_anchor if navigation_enabled? && !history_navigation_key?(key)

  if enabled? && accept_key?(key)
    buffer = whole_buffer
    suggestion = find_suggestion(buffer)

    if suggestion && suggestion != buffer
      accept_suggestion(suggestion)
      return
    end
  end

  super
end

#key_match?(key, config_symbol) ⇒ Boolean

Checks if a key matches a given config symbol.

Parameters:

  • key (Object)

    A Reline key event.

  • config_symbol (Symbol)

    One of :ed_next_char, :ed_end_of_line, :tab, etc.

Returns:

  • (Boolean)


251
252
253
254
255
256
257
258
259
260
261
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 251

def key_match?(key, config_symbol)
  return false unless key.respond_to?(:method_symbol)

  if config_symbol == :tab
    key.method_symbol == :ed_insert &&
      key.respond_to?(:char) &&
      key.char == "\t"
  else
    key.method_symbol == config_symbol
  end
end

Whether prefix-filtered history navigation is enabled. Falls back to the value of enabled? when not explicitly set.

Returns:

  • (Boolean)


209
210
211
212
213
214
215
216
217
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 209

def navigation_enabled?
  case ENV.fetch(ENV_NAV_KEY, nil)
  when '0' then false
  when '1' then true
  else
    val = IRB.conf[CONFIG_NAV_KEY]
    val.nil? ? enabled? : val
  end
end

#renderObject

Parameters:

  • (Object)

Returns:

  • (Object)


27
# File 'sig/lib/irb/autosuggestions/line_editor_patch.rbs', line 27

def render: (*untyped) -> untyped

#render_ghost(ghost, suggestion = nil) ⇒ void

This method returns an undefined value.

Writes the ghost text (inline + extra lines) to terminal output. Saves and restores cursor column so Reline's cursor tracking (used for left/right arrow positioning) is not disturbed.

If suggestion is provided and colorization is enabled, the ghost is rendered with syntax highlighting via IRB::Color.

Parameters:

  • ghost (String)

    The ghost text (suffix of the suggestion).

  • suggestion (String, nil) (defaults to: nil)

    The full matching history entry.



375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 375

def render_ghost(ghost, suggestion = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
  output = Reline.core.instance_variable_get(:@output)
  display_lines = ghost_display_lines(ghost, suggestion)
  @ghost_line_count = display_lines.size - 1
  @has_inline_ghost = true

  output.write(move_to_buffer_end)
  first_line = display_lines.first
  output.write(first_line) if first_line && !first_line.empty?
  write_extra_ghost_lines(display_lines.drop(1))
  origin_column = cursor_column_at_byte_pointer
  output.write("\e[#{@ghost_line_count}A") if @ghost_line_count.positive?
  output.write("\e[0G\e[#{origin_column}C")
  output.flush
end

#render_ghost_suggestionvoid

This method returns an undefined value.

Renders ghost text for the current buffer, if a suggestion exists.



326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 326

def render_ghost_suggestion
  buffer = whole_buffer
  @ghost_line_count = 0
  return if buffer.empty?

  suggestion = find_suggestion(buffer)
  return unless suggestion

  ghost = suggestion[buffer.size..]
  return if ghost.nil? || ghost.empty?

  render_ghost(ghost, suggestion)
end

#rerenderObject

Injects ghost text into terminal output after Reline finishes rendering. Must be public because Reline::Core calls rerender externally. Works on both Reline 0.3.x (rerender is the main rendering entry) and Reline 0.4+ (rerender calls render internally).

Ghost is cleared BEFORE super to avoid cursor-position-dependent escapes targeting wrong lines after accept_suggestion changes buffer.

Returns:

  • (Object)

    The result of super.



62
63
64
65
66
67
68
69
70
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 62

def rerender
  clear_previous_ghost if enabled?

  result = super

  render_ghost_suggestion if enabled?

  result
end

#resolve_ghost_style(hash) ⇒ String

Converts a style hash like { fg: :bright_black, italic: true } to an ANSI escape sequence.

Parameters:

  • hash (Hash)

    Style hash with :fg color and optional attribute keys.

Returns:

  • (String)


429
430
431
432
433
434
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 429

def resolve_ghost_style(hash)
  codes = []
  codes << FG_MAP[hash[:fg]] if hash[:fg]
  ATTR_MAP.each { |attr, code| codes << code if hash[attr] }
  "\e[#{codes.join(';')}m"
end

#use_colorize?Boolean

Checks whether syntax coloring is available and enabled.

Returns:

  • (Boolean)


440
441
442
443
444
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 440

def use_colorize?
  defined?(IRB::Color) &&
    IRB::Color.colorable? &&
    IRB.conf.fetch(:USE_COLORIZE, true)
end

#write_extra_ghost_lines(lines) ⇒ void

This method returns an undefined value.

Writes extra ghost lines below the current buffer line with prompt-width alignment.

Parameters:

  • lines (Array<String>)

    Extra lines with ANSI codes (excluding first inline line).



512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 512

def write_extra_ghost_lines(lines)
  return if lines.empty?

  prompt_width = @prompt ? Reline::Unicode.calculate_width(@prompt) : 0
  output = Reline.core.instance_variable_get(:@output)

  lines.each do |line|
    output.write("\n\e[K")
    output.write("\e[#{prompt_width}C") if prompt_width.positive?
    output.write(line)
  end
end