You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

150 lines
6.8 KiB

---
title: Current Word Completion
date: 2020-05-26
description: ZSH's tab completion is excellent, although limited to completing based on your command. With this, I can also make completions based on my current word.
tags:
- ZSH
- FZF
---
Tab completion is one of the main reasons I decided to make the switch to ZSH. Being able to see descriptions and cycle between options makes finding options a breeze:
![ZSH Tab Completion](/assets/switch-to-zsh/zsh-tab-complete.gif)
However, there are times that I would prefer to have completion options based on the current word I have typed, regardless of the command I am running. I suppose this is more like an extension of ZSH's global aliases, but with the ability to choose from multiple options. An example of this is for word lists. There are a lot of commands I use that require word lists and it is a pain to write out the full path before I am able to tab-complete. My solution is what I have been calling Current Word Completion. My current iteration of it relies on [FZF](https://github.com/junegunn/fzf) for the completions, although in the long term, I would like to make it work with ZSH's built in completion system. Here is a demonstration using `wl` for wordlists and `myip` for my local machines ip addresses.
![Current Word Completion](/assets/word-complete/currentWordComplete.gif)
You can see that it doesn't stop ZSH's normal completion from working and it can work multiple times on any command.
As with tab-completion, I have it bound to my tab key.
If all you want to do is implement it yourself, you can find the source code [here](https://git.jonathanh.co.uk/jab2870/Dotfiles/src/branch/master/shells/zsh/includes/currentwordcompletion.zsh). Simply source the file in your `.zshrc` and you should be good to go. You can add new options to the `word_replace` function.
If you are interested in how it works, keep reading.
## Overriding tab key
The tab key is interpreted by the terminal as ctrl+i for [legacy reasons](https://bestasciitable.com/). That is why I am binding to `^I`.
The code below will check what ctrl+i is currently bound to and store it. This means we can override the tab key in such a way that if we are not on a word that should be expanded, we can fall back to whatever tab used to do.
We then bind ctrl+i to a new function that will spawn a notification and then run whatever ctrl+i used to be bound to.
```zsh
currentwordcomplete(){
notify-send "Yay, it works"
zle ${currentword_default_completion:-expand-or-complete}
}
# Record what ctrl+i is currently set to
# That way we can call it if currentword_default_completion doesn't result in anything
[ -z "$currentword_default_completion" ] && {
binding=$(bindkey '^I')
[[ $binding =~ 'undefined-key' ]] || currentword_default_completion=$binding[(s: :w)2]
unset binding
}
zle -N currentwordcomplete
bindkey '^I' currentwordcomplete
```
You can check this works by sourcing the file and pushing tab. You should get a notification and then normal tab completion should work.
## Splitting up the current line
The `LBUFFER` variable in a ZSH widget contains a string equal to everything before your cursor in the buffer. We can use expansion built into ZSH to turn that into an array of words.
```zsh
tokens=(${(z)LBUFFER})
```
The `z` flag here will expand the string using shell parsing to split the string into arguments. This takes into account quotes and escaped spaces. Full details can be found in [the documentation](http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion-Flags).
This means that we have an array of words. The word we are currently on will be the last:
```zsh
lastWord="${tokens[-1]}"
```
I also get the first argument which is normally going to be the command currently being run. Although I don't use this at the moment, I thought it might be useful to be able to exclude certain commands from completion.
```zsh
cmd="${tokens[1]}"
```
## Word Replace
I have a function called `word_replace` that takes the word that should be replaced and the command, and prints what it should be replaced with. It also returns 0 on success (there was a replacement found).
```zsh
word_replace(){
local ret=1
local word="$1"
local cmd="$2"
case "$word" in
wl) wordlistSelect; return 0 ;;
myip) ip route | grep -oE '(dev|src) [^ ]+' | sed 'N;s/\n/,/;s/src //;s/dev //' | awk -F',' '{print $2 " " $1}' | sort -u | fzf -1 --no-preview | cut -d' ' -f1; return 0 ;;
esac
return "$ret"
}
```
In this case, it is a simple switch statement. An interesting side note is the use of `-1` in the FZF command for `myip`. This will prevent FZF from running if there is only 1 option fed to it. So, if I am only connected on one interface, it will simply fill my ip address rather than prompting me to choose one.
Obviously, the logic used here could be as simple or complex as you wish.
## Getting the completion
Inside the `currentwordcomplete` function, I get the output of the `word_replace` function which is passed the current word. If that doesn't result in a completion, I will run the `word_replace` function again, using only the part of the word that comes after an `=` sign (if there is one).
In either case, a variable called swap will contain what the current word should be replaced with.
There will also be a variable called `ret` that will be equal to 0 if the replacement should be made.
```zsh
currentwordcomplete(){
...
# Check we haven't pushed space
if [ "${LBUFFER[-1]}" != " " ]; then
swap="$(word_replace "$lastWord" "$cmd")"
ret="$?"
# This part checks if the part after an = is completable
if [ "$ret" -ne "0" ]; then
local afterEqual="${lastWord##*=}"
local beforeEqual="${lastWord%=*}"
# If they are different, there is an equals in the word
if [ "$afterEqual" != "$lastWord" ]; then
swap="${beforeEqual}=$(word_replace "$afterEqual" "$cmd")"
ret="$?"
fi
fi
fi
...
}
```
## Making the change
Finally, I check if the completion should be made. If it shouldn't, I call whatever function the tab key used to be bound to. If it should, I change the last item of the tokens array that we created earlier. I then set the LBUFFER variable to the changed string.
```zsh
if [ "$ret" -eq "0" ]; then
if [ -n "$swap" ]; then
tokens[-1]="$swap"
LBUFFER="${tokens[@]}"
fi
zle reset-prompt
return 0
else
zle ${currentword_default_completion:-expand-or-complete}
return
fi
```
## Conclusion
This is a relatively un-intrusive addition to ZSH that I use most days. I don't use a huge number of these but the two I mentioned here, word lists and my ip, I use a lot.
You can find the [full source here](https://git.jonathanh.co.uk/jab2870/Dotfiles/src/branch/master/shells/zsh/includes/currentwordcompletion.zsh). If you are interested in my full [ZSH config is here](https://git.jonathanh.co.uk/jab2870/Dotfiles/src/branch/master/shells/zsh/).