Skip to content

Commit

Permalink
Support tab completion for variables.
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
georgebrock committed Jun 20, 2017
1 parent 1b673e5 commit 33014f7
Show file tree
Hide file tree
Showing 14 changed files with 354 additions and 10 deletions.
8 changes: 8 additions & 0 deletions lib/gitsh/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/gitsh/git_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions lib/gitsh/magic_variables.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
13 changes: 11 additions & 2 deletions lib/gitsh/tab_completion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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.
16 changes: 16 additions & 0 deletions lib/gitsh/tab_completion/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
[]
Expand Down
11 changes: 10 additions & 1 deletion lib/gitsh/tab_completion/facade.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions lib/gitsh/tab_completion/variable_completer.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions spec/integration/tab_completion_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
24 changes: 24 additions & 0 deletions spec/units/environment_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions spec/units/git_repository_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions spec/units/magic_variables_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions spec/units/tab_completion/context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Loading

0 comments on commit 33014f7

Please sign in to comment.