From 10448d6bb33448bb112ccbcfce2d6933e419fdf2 Mon Sep 17 00:00:00 2001 From: Piotr Murach Date: Fri, 16 Jul 2021 23:54:58 +0200 Subject: [PATCH] Add ability to complete words Continued pressing of the tab key inserts the next completion suggestion into a line. When all completion suggestions are exhausted, the original word is restored back. --- CHANGELOG.md | 1 + lib/tty/reader.rb | 28 +++++-- lib/tty/reader/completer.rb | 100 ++++++++++++++++++++++ spec/unit/complete_word_spec.rb | 142 ++++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 lib/tty/reader/completer.rb create mode 100644 spec/unit/complete_word_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f094a..8ed05f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added * Add to line the ability to find a word to complete before the cursor +* Add ability to complete words * Add :history_size keyword to allow configuration of maximum history size * Add the ability to replace a current line in the history buffer * Add support for preserving edits to lines stored in history diff --git a/lib/tty/reader.rb b/lib/tty/reader.rb index b48744f..3d1957e 100644 --- a/lib/tty/reader.rb +++ b/lib/tty/reader.rb @@ -4,7 +4,7 @@ require "tty-screen" require "wisper" -require_relative "reader/completions" +require_relative "reader/completer" require_relative "reader/history" require_relative "reader/line" require_relative "reader/key_event" @@ -22,10 +22,11 @@ class Reader include Wisper::Publisher # Key codes + BACKSPACE = 8 + TAB = 9 + NEWLINE = 10 CARRIAGE_RETURN = 13 - NEWLINE = 10 - BACKSPACE = 8 - DELETE = 127 + DELETE = 127 # Keys that terminate input EXIT_KEYS = %i[ctrl_d ctrl_z].freeze @@ -56,6 +57,11 @@ def self.windows? attr_reader :track_history alias track_history? track_history + # The handler for finding word completion suggestions + # + # @api public + attr_accessor :completion_handler + attr_reader :console attr_reader :cursor @@ -78,13 +84,16 @@ def self.windows? # allow duplicate entires, false by default # @param [Proc] history_exclude # exclude lines from history, by default all lines are stored + # @param [Proc] completion_handler + # the hanlder for finding word completion suggestions # # @api public def initialize(input: $stdin, output: $stdout, interrupt: :error, env: ENV, track_history: true, history_cycle: false, history_exclude: History::DEFAULT_EXCLUDE, history_size: History::DEFAULT_SIZE, - history_duplicates: false) + history_duplicates: false, + completion_handler: nil) @input = input @output = output @interrupt = interrupt @@ -94,6 +103,8 @@ def initialize(input: $stdin, output: $stdout, interrupt: :error, @history_exclude = history_exclude @history_duplicates = history_duplicates @history_size = history_size + @completion_handler = completion_handler + @completer = Completer.new(handler: completion_handler) @console = select_console(input) @history = History.new(history_size) do |h| @@ -255,6 +266,7 @@ def read_line(prompt = "", value: "", echo: true, raw: true, line = Line.new(value, prompt: prompt) screen_width = TTY::Screen.width history_in_use = false + previous_key_name = "" buffer = "" output.print(line) @@ -273,7 +285,9 @@ def read_line(prompt = "", value: "", echo: true, raw: true, clear_display(line, screen_width) end - if key_name == :backspace || code == BACKSPACE + if (key_name == :tab || code == TAB) && completion_handler + @completer.complete(line, initial: previous_key_name != :tab) + elsif key_name == :backspace || code == BACKSPACE if !line.start? line.left line.delete @@ -321,6 +335,8 @@ def read_line(prompt = "", value: "", echo: true, raw: true, end end + previous_key_name = key_name + # trigger before line is printed to allow for line changes trigger_key_event(char, line: line.to_s) diff --git a/lib/tty/reader/completer.rb b/lib/tty/reader/completer.rb new file mode 100644 index 0000000..59f1eb1 --- /dev/null +++ b/lib/tty/reader/completer.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "completions" + +module TTY + class Reader + # Responsible for word completion + # + # @api private + class Completer + # The completion suggestions + attr_reader :completions + + # The handler for finding word completion suggestions + attr_reader :handler + + # The word to complete + attr_reader :word + + # Create a Completer instance + # + # @api private + def initialize(handler: nil) + @handler = handler + @completions = Completions.new + @show_initial = false + @word = "" + end + + # Find a suggestion to complete a word + # + # @param [Line] line + # the line to complete a word in + # + # @return [Boolean, String] + # the completed word or false when no suggestion is found + # + # @api public + def complete(line, initial: false) + initial ? complete_initial(line) : complete_next(line) + end + + # Find suggestions and complete the initial word + # + # @param [Line] line + # the line to complete a word in + # + # @return [Boolean, String] + # the completed word or false when no suggestion is found + # + # @api public + def complete_initial(line) + @word = line.word_to_complete + suggestions = handler.(word) + completions.clear + + return false if suggestions.empty? + + completions.concat(suggestions) + completed_word = completions.get + + line.remove(word.length) + line.insert(completed_word) + + completed_word + end + + # Complete a word with the next suggestion from completions + # + # @param [Line] line + # the line to complete a word in + # + # @return [Boolean, String] + # the completed word or false when no suggestion is found + # + # @api public + def complete_next(line) + return false if completions.empty? + + previous_suggestion = completions.get + if completions.last? && !@show_initial + @show_initial = true + completed_word = word + else + if @show_initial + @show_initial = false + previous_suggestion = word + end + completions.next + completed_word = completions.get + end + + line.remove(previous_suggestion.length) + line.insert(completed_word) + + completed_word + end + end # Completer + end # Reader +end # TTY diff --git a/spec/unit/complete_word_spec.rb b/spec/unit/complete_word_spec.rb new file mode 100644 index 0000000..68ae55c --- /dev/null +++ b/spec/unit/complete_word_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +RSpec.describe TTY::Reader, "complete word" do + let(:input) { StringIO.new } + let(:output) { StringIO.new } + let(:env) { {"TTY_TEST" => true} } + let(:left) { "\e[D" } + let(:options) { + {input: input, output: output, env: env, + completion_handler: ->(word) do + @completions.grep(/\A#{Regexp.escape(word)}/) + end} + } + + subject(:reader) { described_class.new(**options) } + + it "finds no completions for a word" do + @completions = %w[aa ab ac] + input << "x" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x\n") + output.rewind + expect(output.string).to eq("\e[2K\e[1Gx\e[2K\e[1Gx\e[2K\e[1Gx\n") + end + + it "completes an empty line with the first suggestion" do + @completions = %w[aa ab ac] + input << "" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("aa\n") + output.rewind + expect(output.string).to eq("\e[2K\e[1Gaa\e[2K\e[1Gaa\n") + end + + it "completes space inside line with the first suggestion" do + @completions = %w[aa ab ac] + input << "x" << " " << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x aa\n") + end + + it "completes a word using the first suggestion" do + @completions = %w[aa ab ac] + input << "x" << " " << "a" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x aa\n") + end + + it "completes a word using the next suggestion" do + @completions = %w[aa ab ac] + input << "x" << " " << "a" << "\t" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x ab\n") + end + + it "completes a word using the last suggestion" do + @completions = %w[aa ab ac] + input << "x " << "a" << "\t" << "\t" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x ac\n") + end + + it "cycles through completions back to the initial word" do + @completions = %w[aa ab ac] + input << "x " << "a" << "\t" << "\t" << "\t" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x a\n") + end + + it "cycles through completions and completes using the first suggestion" do + @completions = %w[aa ab ac] + input << "x " << "a" << "\t" << "\t" << "\t" << "\t" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x aa\n") + end + + it "resets suggestions when a new character is entered" do + @completions = %w[aa ab ac] + input << "x " << "a" << "\t" << "\b" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x aa\n") + end + + it "completes edited text within a line" do + @completions = %w[aa ab ac] + input << "bb" << left << left << " " << left + input << "a" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("aa bb\n") + end + + it "completes within a word" do + @completions = %w[aa ab ac] + input << "x " << "aa" << left << "\t" << "\t" << "\n" + input.rewind + + answer = reader.read_line + + expect(answer).to eq("x aba\n") + end + + it "completes a multiline input " do + @completions = %w[aa ab ac] + input << "a" << "\t" << "\n" + input << "a" << "\t" << "\t" << "\C-d" + input.rewind + + answer = reader.read_multiline + + expect(answer).to eq(%W[aa\n ab]) + end +end