lib/toys/utils/completion_engine.rb
# frozen_string_literal: true module Toys module Utils ## # Implementations of tab completion. # # This module is not loaded by default. Before using it directly, you must # `require "toys/utils/completion_engine"` # module CompletionEngine ## # A completion engine for bash. # class Bash ## # Create a bash completion engine. # # @param cli [Toys::CLI] The CLI. # def initialize(cli) require "shellwords" @cli = cli end ## # Perform completion in the current shell environment, which must # include settings for the `COMP_LINE` and `COMP_POINT` environment # variables. Prints out completion candidates, one per line, and # returns a status code indicating the result. # # * **0** for success. # * **1** if completion failed. # * **2** if the environment is incorrect (e.g. expected environment # variables not found) # # @return [Integer] status code # def run return 2 if !::ENV.key?("COMP_LINE") || !::ENV.key?("COMP_POINT") line = ::ENV["COMP_LINE"].to_s point = ::ENV["COMP_POINT"].to_i point = line.length if point.negative? line = line[0, point] completions = run_internal(line) if completions completions.each { |completion| puts completion } 0 else 1 end end ## # Internal completion method designed for testing. # # @private # def run_internal(line) words = CompletionEngine.split(line) quote_type, last = words.pop return nil unless words.shift words.map! { |_type, word| word } prefix = "" if (match = /\A(.*[=:])(.*)\z/.match(last)) prefix = match[1] last = match[2] end context = Completion::Context.new( cli: @cli, previous_words: words, fragment_prefix: prefix, fragment: last, params: {shell: :bash, quote_type: quote_type} ) candidates = @cli.completion.call(context) candidates.uniq.sort.map do |candidate| CompletionEngine.format_candidate(candidate, quote_type) end end end class << self ## # @private # def split(line) words = [] field = ::String.new quote_type = nil line.scan(split_regex) do |word, sqw, dqw, esc, garbage, sep| raise ArgumentError, "Didn't expect garbage: #{line.inspect}" if garbage field << field_str(word, sqw, dqw, esc) quote_type = update_quote_type(quote_type, sqw, dqw) if sep words << [quote_type, field] quote_type = nil field = sep.empty? ? nil : ::String.new end end words << [quote_type, field] if field words end ## # @private # def format_candidate(candidate, quote_type) str = candidate.to_s partial = candidate.is_a?(Completion::Candidate) ? candidate.partial? : false quote_type = nil if candidate.string.include?("'") && quote_type == :single case quote_type when :single partial ? "'#{str}" : "'#{str}' " when :double str = str.gsub(/[$`"\\\n]/, '\\\\\\1') partial ? "\"#{str}" : "\"#{str}\" " else str = ::Shellwords.escape(str) partial ? str : "#{str} " end end private def split_regex word_re = "([^\\s\\\\\\'\\\"]+)" sq_re = "'([^\\']*)(?:'|\\z)" dq_re = "\"((?:[^\\\"\\\\]|\\\\.)*)(?:\"|\\z)" esc_re = "(\\\\.?)" sep_re = "(\\s|\\z)" /\G\s*(?>#{word_re}|#{sq_re}|#{dq_re}|#{esc_re}|(\S))#{sep_re}?/m end def field_str(word, sqw, dqw, esc) word || sqw || dqw&.gsub(/\\([$`"\\\n])/, '\\1') || esc&.gsub(/\\(.)/, '\\1') || "" end def update_quote_type(quote_type, sqw, dqw) if quote_type :multi elsif sqw :single elsif dqw :double else :bare end end end end end end