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)


248
249
250
251
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 248

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_matching_suggestion?Boolean

Accepts the current suggestion when it extends the buffer.

Returns:

  • (Boolean)

    true when a suggestion was accepted



52
53
54
55
56
57
58
59
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 52

def accept_matching_suggestion?
  buffer = whole_buffer
  suggestion = find_suggestion(buffer)
  return false unless suggestion && suggestion != buffer

  accept_suggestion(suggestion)
  true
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.



463
464
465
466
467
468
469
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 463

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, ghost_byte_start = nil) ⇒ Array<String>

Colorizes the full suggestion and extracts the ghost portion.

ghost_byte_start is the visible-byte offset of the ghost within the suggestion. It must come from the full (untruncated) ghost: using a truncated ghost shifts the offset into the middle of the suggestion and the colored suffix no longer matches the buffer. Colored lines are then truncated to fit the terminal width, same as the plain-text ghost.

Parameters:

  • ghost (String)

    The ghost text (suffix of the suggestion).

  • suggestion (String)

    The full matching history entry.

  • ghost_byte_start (Integer, nil) (defaults to: nil)

    Visible-byte offset of the ghost.

Returns:

  • (Array<String>)

    Colorized ghost lines with ANSI codes.



571
572
573
574
575
576
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 571

def colorize_ghost_lines(ghost, suggestion, ghost_byte_start = nil)
  colored = IRB::Color.colorize_code(suggestion)
  start = ghost_byte_start || (suggestion.bytesize - ghost.bytesize)
  colored_ghost = extract_ansi_colored_suffix(colored, start)
  truncate_colored_ghost(colored_ghost).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.



641
642
643
644
645
646
647
648
649
650
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 641

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)


231
232
233
234
235
236
237
238
239
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 231

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)


659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 659

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)


296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 296

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)


277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 277

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.



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

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)


527
528
529
530
531
532
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 527

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, ghost_byte_start = nil) ⇒ 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.

  • ghost_byte_start (Integer, nil) (defaults to: nil)

    Byte offset of the ghost within the full suggestion (pre-truncation), used to extract the colored suffix.

Returns:

  • (Array<String>)

Raises:

  • (StandardError)


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

def ghost_display_lines(ghost, suggestion, ghost_byte_start = nil)
  if suggestion && use_colorize?
    colorize_ghost_lines(ghost, suggestion, ghost_byte_start)
  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
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 37

def input_key(key)
  # Some keys (Enter, submission) bypass rerender, so ghost is cleared
  # on every key to avoid stale ghost lines leftover on screen.
  clear_previous_ghost if enabled?

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

  return if enabled? && accept_key?(key) && accept_matching_suggestion?

  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)


259
260
261
262
263
264
265
266
267
268
269
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 259

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)


217
218
219
220
221
222
223
224
225
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 217

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, ghost_byte_start = 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.

  • ghost_byte_start (Integer, nil) (defaults to: nil)

    Byte offset of the ghost within the full suggestion (pre-truncation), used to extract the colored suffix.



484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 484

def render_ghost(ghost, suggestion = nil, ghost_byte_start = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
  output = Reline.core.instance_variable_get(:@output)
  display_lines = ghost_display_lines(ghost, suggestion, ghost_byte_start)
  @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.

The ghost is reduced to fit the terminal width so it never wraps onto the following row. A ghost that wraps corrupts Reline's single-row cursor model (duplicated text, stale ANSI state), because Reline does not track visual wrapping of long lines. Suggestions remain accept-able via the configured keys even when the ghost is truncated.



341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 341

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(truncate_ghost_to_terminal(ghost), suggestion, suggestion.bytesize - ghost.bytesize)
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.



70
71
72
73
74
75
76
77
78
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 70

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)


540
541
542
543
544
545
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 540

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)


551
552
553
554
555
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 551

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).



686
687
688
689
690
691
692
693
694
695
696
# File 'lib/irb/autosuggestions/line_editor_patch.rb', line 686

def write_extra_ghost_lines(lines)
  return if lines.empty?

  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