From 33014f700390362fee824656cbba77384c5da74e Mon Sep 17 00:00:00 2001 From: George Brocklehurst Date: Sun, 30 Apr 2017 15:06:27 -0400 Subject: [PATCH] Support tab completion for variables. This commit introduces a `Gitsh::TabCompletion::VariableCompleter` object. This is completely separate from the NFA-based completion we use for most things, because: - we immediately know we need to use variable completion based on lexical analysis of the input without having to walk through the NFA's states, and - the context in which the variable appears doesn't inform the completions we present the user with. The `Gitsh::TabCompletion::Facade` object gains the responsibility of deciding which type of completion to invoke, based on information from the `Gitsh::TabCompletion::Context` object. To load the config variables, we try to use the `git status --list --name-only` command. However, this command was only introduced in Git 2.6, and versions back to 2.4 are still officially supported by the Git team. If running the command with `--name-only` fails we fall back to parsing the output of `git status --list`. We don't use the backward compatible version everywhere, because the parsing won't be perfect for multi-line config values (`--name-only` was introduced to address this problem). --- lib/gitsh/environment.rb | 8 ++ lib/gitsh/git_repository.rb | 20 +++++ lib/gitsh/magic_variables.rb | 8 +- lib/gitsh/tab_completion/README.md | 13 ++- lib/gitsh/tab_completion/context.rb | 16 ++++ lib/gitsh/tab_completion/facade.rb | 11 ++- .../tab_completion/variable_completer.rb | 51 ++++++++++++ spec/integration/tab_completion_spec.rb | 10 +++ spec/units/environment_spec.rb | 24 ++++++ spec/units/git_repository_spec.rb | 14 ++++ spec/units/magic_variables_spec.rb | 14 ++++ spec/units/tab_completion/context_spec.rb | 22 +++++ spec/units/tab_completion/facade_spec.rb | 73 ++++++++++++++++- .../tab_completion/variable_completer_spec.rb | 80 +++++++++++++++++++ 14 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 lib/gitsh/tab_completion/variable_completer.rb create mode 100644 spec/units/tab_completion/variable_completer_spec.rb diff --git a/lib/gitsh/environment.rb b/lib/gitsh/environment.rb index d6c771d5..92e514e5 100644 --- a/lib/gitsh/environment.rb +++ b/lib/gitsh/environment.rb @@ -50,6 +50,14 @@ def fetch(key, force_default_git_command = false, &block) raise Gitsh::UnsetVariableError, "Variable '#{key}' is not set" end + def available_variables + ( + magic_variables.available_variables + + variables.keys + + repo.available_config_variables + ).uniq.sort + end + def config_variables Hash[variables.select { |key, value| key.to_s.include?('.') }] end diff --git a/lib/gitsh/git_repository.rb b/lib/gitsh/git_repository.rb index 41331308..6e7fe4bc 100644 --- a/lib/gitsh/git_repository.rb +++ b/lib/gitsh/git_repository.rb @@ -67,6 +67,11 @@ def config(name, force_default_git_command = false) end end + def available_config_variables + modern_git_available_config_variables || + old_git_available_config_variables + end + def config_color(name, default) git_output( "config --get-color #{Shellwords.escape(name)} #{Shellwords.escape(default)}" @@ -118,6 +123,21 @@ def abbreviated_sha end end + def modern_git_available_config_variables + command = git_command("config --list --name-only") + out, _, status = Open3.capture3(command) + + if status.success? + out.lines.map { |line| line.chomp.to_sym } + end + end + + def old_git_available_config_variables + git_output('config --list').lines.map do |line| + line.split('=').first.to_sym + end + end + def git_output(command) Open3.capture3(git_command(command)).first.chomp end diff --git a/lib/gitsh/magic_variables.rb b/lib/gitsh/magic_variables.rb index 147fac40..1a51e115 100644 --- a/lib/gitsh/magic_variables.rb +++ b/lib/gitsh/magic_variables.rb @@ -12,14 +12,14 @@ def fetch(key) end end - private - - attr_reader :repo - def available_variables private_methods(false).grep(/^_/) end + private + + attr_reader :repo + def _prior repo.revision_name('@{-1}') || raise(UnsetVariableError, 'No prior branch') diff --git a/lib/gitsh/tab_completion/README.md b/lib/gitsh/tab_completion/README.md index 5c2d3ae9..b9b855c2 100644 --- a/lib/gitsh/tab_completion/README.md +++ b/lib/gitsh/tab_completion/README.md @@ -10,6 +10,9 @@ has typed so far. The basis of gitsh's tab completion system is a slightly extended Non-deterministic Finite Automaton (NFA). +Completing variable names doesn't use the NFA, but all other types of completion +do. + ### Finite Automata In a typical NFA, the automaton consists of a set of states joined by @@ -104,10 +107,12 @@ so we'd offer file paths from the current directory as completions. - `Gitsh::TabCompletion::Facade` provides a single interface to the rest of gitsh. It's the only class inside the `TabCompletion` module that should be referenced elsewhere. Its `#call` method is the entry point for generating - completions. + completions. It also decides if the `CommandCompleter` or `VariableCompleter` + should be invoked. - `Gitsh::TabCompletion::Context` uses the `Gitsh::Lexer` to break up the user's - input into a series of words, so we know what to pass to the automaton. + input into a series of words, so we know what to pass to the automaton, or if + the input ends with a variable name. - `Gitsh::TabCompletion::Automaton` implements the Non-deterministic Finite Automaton (NFA). @@ -123,3 +128,7 @@ so we'd offer file paths from the current directory as completions. - `Gitsh::TabCompletion::CommandCompleter` orchestrates the interaction of the various other parts. + +- `Gitsh::TabCompletion::VariableCompleter` provides an alternative to + `CommandCompleter`. This doesn't use the automaton, and only completes + variable names. diff --git a/lib/gitsh/tab_completion/context.rb b/lib/gitsh/tab_completion/context.rb index d58f609c..7dd41e3d 100644 --- a/lib/gitsh/tab_completion/context.rb +++ b/lib/gitsh/tab_completion/context.rb @@ -6,6 +6,7 @@ class Context COMMAND_SEPARATORS = [ :AND, :OR, :SEMICOLON, :LEFT_PAREN, :SUBSHELL_START, :EOL, ].freeze + NOT_MEANINGFUL = [:EOS, :INCOMPLETE].freeze def initialize(input) @input = input @@ -15,6 +16,10 @@ def prior_words words[0...-1] end + def completing_variable? + [:VAR, :MISSING].include?(last_meaningful_token.type) + end + private attr_reader :input @@ -28,6 +33,8 @@ def combine_words(token_groups) tokens.inject("") do |result, token| if token.type == :WORD result + token.value + elsif token.type == :VAR + result + "${#{token.value}}" else result end @@ -53,7 +60,16 @@ def last_command(tokens) reverse end + def last_meaningful_token + tokens.reverse_each. + detect { |token| !NOT_MEANINGFUL.include?(token.type) } + end + def tokens + @_tokens ||= lex + end + + def lex Lexer.lex(input) rescue RLTK::LexingError [] diff --git a/lib/gitsh/tab_completion/facade.rb b/lib/gitsh/tab_completion/facade.rb index d83a8d0c..60206bdc 100644 --- a/lib/gitsh/tab_completion/facade.rb +++ b/lib/gitsh/tab_completion/facade.rb @@ -2,6 +2,7 @@ require 'gitsh/tab_completion/command_completer' require 'gitsh/tab_completion/context' require 'gitsh/tab_completion/escaper' +require 'gitsh/tab_completion/variable_completer' module Gitsh module TabCompletion @@ -13,7 +14,11 @@ def initialize(line_editor, env) def call(input) context = Context.new(line_editor.line_buffer) - command_completions(context, input) + if context.completing_variable? + variable_completions(input) + else + command_completions(context, input) + end end private @@ -30,6 +35,10 @@ def command_completions(context, input) ).call end + def variable_completions(input) + VariableCompleter.new(line_editor, input, env).call + end + def automaton @automaton ||= AutomatonFactory.build(env) end diff --git a/lib/gitsh/tab_completion/variable_completer.rb b/lib/gitsh/tab_completion/variable_completer.rb new file mode 100644 index 00000000..28ea4bb7 --- /dev/null +++ b/lib/gitsh/tab_completion/variable_completer.rb @@ -0,0 +1,51 @@ +module Gitsh + module TabCompletion + class VariableCompleter + def initialize(line_editor, input, env) + @line_editor = line_editor + @input = input + @env = env + end + + def call + line_editor.completion_append_character = completion_append_character + line_editor.completion_suppress_quote = true + + matches + end + + private + + attr_reader :line_editor, :input, :env + + def completion_append_character + if prefix.end_with?('{') + '}' + else + nil + end + end + + def matches + env.available_variables. + select { |name| name.to_s.start_with?(partial_name) }. + map { |name| "#{prefix}#{name}" } + end + + def prefix + parse_input.first + end + + def partial_name + parse_input.last + end + + def parse_input + @parse_input ||= ( + parts = input.rpartition(/\$\{?/) + [parts[0...-1].join, parts.last] + ) + end + end + end +end diff --git a/spec/integration/tab_completion_spec.rb b/spec/integration/tab_completion_spec.rb index ef5f8814..95474163 100644 --- a/spec/integration/tab_completion_spec.rb +++ b/spec/integration/tab_completion_spec.rb @@ -124,6 +124,16 @@ end end + it 'completes variables' do + GitshRunner.interactive do |gitsh| + gitsh.type(':set greeting "Hello, world"') + gitsh.type(":echo $gre\t") + + expect(gitsh).to output_no_errors + expect(gitsh).to output(/Hello, world/) + end + end + it 'completes after punctuation' do GitshRunner.interactive do |gitsh| gitsh.type('init') diff --git a/spec/units/environment_spec.rb b/spec/units/environment_spec.rb index 79b729c7..2842164f 100644 --- a/spec/units/environment_spec.rb +++ b/spec/units/environment_spec.rb @@ -67,6 +67,30 @@ end end + describe '#available_variables' do + it 'returns the names of all available variables' do + repository = double('GitRepository') + allow(repository).to receive(:available_config_variables). + and_return([:'user.name']) + factory = double('RepositoryFactory', new: repository) + magic_variables = double('MagicVariables') + allow(magic_variables).to receive(:available_variables). + and_return([:_prior]) + env = described_class.new( + magic_variables: magic_variables, + repository_factory: factory, + ) + env[:foo] = 'bar' + env['user.name'] = 'Config Override' + + expect(env.available_variables).to eq [ + :_prior, + :foo, + :'user.name', + ] + end + end + describe '#clone' do it 'creates a copy with an isolated set of variables' do original = described_class.new diff --git a/spec/units/git_repository_spec.rb b/spec/units/git_repository_spec.rb index 00bba84e..b6075c2c 100644 --- a/spec/units/git_repository_spec.rb +++ b/spec/units/git_repository_spec.rb @@ -200,6 +200,20 @@ end end + describe '#available_config_variables' do + it 'returns a list of all Git configuration variables' do + with_a_temporary_home_directory do + in_a_temporary_directory do + repo = described_class.new(env) + run 'git init' + run 'git config --local user.name "Grace Hopper"' + + expect(repo.available_config_variables).to include(:'user.name') + end + end + end + end + context '#config_color' do context 'when the config variable is set' do it 'returns a color code for the color described by the setting' do diff --git a/spec/units/magic_variables_spec.rb b/spec/units/magic_variables_spec.rb index 873d3387..0aa7ad63 100644 --- a/spec/units/magic_variables_spec.rb +++ b/spec/units/magic_variables_spec.rb @@ -116,4 +116,18 @@ end end end + + describe '#available_variables' do + it 'returns an array of variable names' do + repo = double('GitRepository') + magic_variables = described_class.new(repo) + + expect(magic_variables.available_variables).to match_array [ + :_prior, + :_merge_base, + :_rebase_base, + :_root, + ] + end + end end diff --git a/spec/units/tab_completion/context_spec.rb b/spec/units/tab_completion/context_spec.rb index 9ef11bab..0c1777be 100644 --- a/spec/units/tab_completion/context_spec.rb +++ b/spec/units/tab_completion/context_spec.rb @@ -8,6 +8,11 @@ expect(context.prior_words).to eq %w(stash drop) end + it 'includes variables' do + context = described_class.new(':echo "name=$user.name" "email=') + expect(context.prior_words).to eq [':echo', 'name=${user.name}'] + end + it 'only considers the current command' do context = described_class.new('stash apply my-stash && stash drop my-') expect(context.prior_words).to eq %w(stash drop) @@ -42,4 +47,21 @@ end end end + + describe '#completing_variable?' do + it 'returns true when the command ends with a variable' do + expect(described_class.new(':echo $my_va')).to be_completing_variable + expect(described_class.new(':echo "$my_va')).to be_completing_variable + expect(described_class.new(':echo ${my_va')).to be_completing_variable + expect(described_class.new(':echo $')).to be_completing_variable + expect(described_class.new(':echo ${')).to be_completing_variable + end + + it 'returns false when the command does not end with a variable' do + expect(described_class.new(':echo hello')).not_to be_completing_variable + expect(described_class.new(':echo $my_var ')).not_to be_completing_variable + expect(described_class.new(':echo \'$varish')).not_to be_completing_variable + expect(described_class.new(':echo \'$')).not_to be_completing_variable + end + end end diff --git a/spec/units/tab_completion/facade_spec.rb b/spec/units/tab_completion/facade_spec.rb index c001099a..26aecd64 100644 --- a/spec/units/tab_completion/facade_spec.rb +++ b/spec/units/tab_completion/facade_spec.rb @@ -3,10 +3,77 @@ describe Gitsh::TabCompletion::Facade do describe '#call' do - it 'presents a convenient interface to the world' do - facade = described_class.new(double(:line_editor), double(:env)) + context 'given input not ending with a variable' do + it 'invokes the CommandCompleter' do + input = 'add -p $path lib/' + line_editor = double(:line_editor, line_buffer: input) + command_completer = stub_command_completer + stub_variable_completer + automaton = stub_automaton_factory + escaper = stub_escaper + facade = described_class.new(line_editor, double(:env)) - expect(facade).to respond_to(:call) + facade.call('lib/') + + expect(Gitsh::TabCompletion::CommandCompleter).to have_received(:new).with( + line_editor, + ['add', '-p', '${path}'], + 'lib/', + automaton, + escaper, + ) + expect(command_completer).to have_received(:call) + expect(Gitsh::TabCompletion::VariableCompleter). + not_to have_received(:new) + end + end + + context 'given input ending with a variable' do + it 'invokes the VariableCompleter' do + input = ':echo "name=$g' + line_editor = double(:line_editor, line_buffer: input) + stub_command_completer + variable_completer = stub_variable_completer + env = double(:env) + facade = described_class.new(line_editor, env) + + facade.call('name=$g') + + expect(Gitsh::TabCompletion::VariableCompleter).to have_received(:new).with( + line_editor, + 'name=$g', + env, + ) + expect(variable_completer).to have_received(:call) + expect(Gitsh::TabCompletion::CommandCompleter). + not_to have_received(:new) + end end end + + def stub_command_completer + stub_class(Gitsh::TabCompletion::CommandCompleter).tap do |completer| + allow(completer).to receive(:call) + end + end + + def stub_variable_completer + stub_class(Gitsh::TabCompletion::VariableCompleter).tap do |completer| + allow(completer).to receive(:call) + end + end + + def stub_automaton_factory + stub_class(Gitsh::TabCompletion::AutomatonFactory, :build) + end + + def stub_escaper + stub_class(Gitsh::TabCompletion::Escaper) + end + + def stub_class(klass, method = :new) + command_completer = instance_double(klass) + allow(klass).to receive(method).and_return(command_completer) + command_completer + end end diff --git a/spec/units/tab_completion/variable_completer_spec.rb b/spec/units/tab_completion/variable_completer_spec.rb new file mode 100644 index 00000000..e8c5247e --- /dev/null +++ b/spec/units/tab_completion/variable_completer_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' +require 'gitsh/tab_completion/variable_completer' + +describe Gitsh::TabCompletion::VariableCompleter do + describe '#call' do + context 'with a variable not wrapped in braces' do + it 'produces variable completions that match the input' do + completer = described_class.new( + build_line_editor, + '$us', + build_env(variables: ['user.name', 'user.email', 'greeting']), + ) + + expect(completer.call).to match_array ['$user.name', '$user.email'] + end + + it 'prefixes the completions with the prefix, if there is one' do + completer = described_class.new( + build_line_editor, + 'name=$us', + build_env(variables: ['user.name', 'user.email', 'greeting']), + ) + + expect(completer.call). + to match_array ['name=$user.name', 'name=$user.email'] + end + + it 'configures the line editor to append a space and not close quotes' do + line_editor = build_line_editor + completer = described_class.new(line_editor, '$us', build_env) + + completer.call + + expect(line_editor). + to have_received(:completion_append_character=).with(nil) + expect(line_editor). + to have_received(:completion_suppress_quote=).with(true) + end + end + + context 'with a variable wrapped in braces' do + it 'produces variable completions that match the input' do + completer = described_class.new( + build_line_editor, + '${us', + build_env(variables: ['user.name', 'user.email', 'greeting']), + ) + + expect(completer.call).to match_array ['${user.name', '${user.email'] + end + + it 'configures the line editor to append a closing brace and not close quotes' do + line_editor = build_line_editor + completer = described_class.new(line_editor, '${us', build_env) + + completer.call + + expect(line_editor). + to have_received(:completion_append_character=).with('}') + expect(line_editor). + to have_received(:completion_suppress_quote=).with(true) + end + end + end + + def build_line_editor + double( + 'LineEditor', + :completion_append_character= => nil, + :completion_suppress_quote= => nil, + ) + end + + def build_env(variables: []) + double( + 'Environment', + available_variables: variables.map(&:to_sym), + ) + end +end