I know you are using bash, and I am not confident that what you ask is possible in bash. What I will show you is how to implement the requested feature in ZSH. (ZSH is a bit like an improved bash - if you switch then you should still remain proficient).
In ZSH there is the ZSH Line Editor (zle for short). This provides all of the movement keys as bindable commands, much like bash. Where it goes further is the ability to define custom commands. A custom command is any shell function that has been turned into a widget.
These functions can execute other commands, and they also get access to several variables that are of interest for your problem. The ones I will talk about are:
- $BUFFER - this is the entire line you are currently editing
- $CURSOR - this is the position of the insert in the current line
There are also others available, like:
- $LBUFFER - this is everything before the cursor
- $RBUFFER - this is everything after the cursor
Now it happens that ZSH is not only capable of providing custom keybinds, it also has a far more comprehensive set of operations that you can perform on variables. One of the ones that is interesting for this problem is:
- z - Split the result of the expansion into words using shell parsing to find the words, i.e. taking into account any quoting in the value.
You can assign the expanded $BUFFER directly to a variable, like so:
line=${(z)BUFFER}
(line is now an array, but annoyingly this array starts at index 1, unlike bash!)
This will not perform any expansion of globbing characters, so it will return an array of the actual arguments in your current line. Once you have this, you are then interested in the position of the start point of each word in the buffer. Unfortunately you may well have multiple spaces between any two words, as well as repeated words. The best thing I can think of at this point is to remove each word being considered from the current buffer as you consider it. Something like:
buffer=$BUFFER
words=${(z)buffer}
for word in $words[@]
do
# doing regular expression matching here,
# so need to quote every special char in $word.
escaped_word=${(q)word}
# Fancy ZSH to the rescue! (q) will quote the special characters in a string.
# Pattern matching like this creates $MBEGIN $MEND and $MATCH, when successful
if [[ ! ${buffer} =~ ${${(q)word}:gs#\\\'#\'#} ]]
then
echo "Something strange happened... no match for current word"
return 1
fi
buffer=${buffer[$MEND,-1]}
done
We are almost there now! What is needed is a way to see which word is the last word before the cursor, and which word is the beginning of the next word after the cursor.
buffer=$BUFFER
words=${(z)buffer}
index=1
for word in $words[@]
do
if [[ ! ${buffer} =~ ${${(q)word}:gs#\\\'#\'#} ]]
then
echo "Something strange happened... no match for current word"
return 1
fi
old_length=${#buffer}
buffer=${buffer[$MEND,-1]}
new_length=${#buffer}
old_index=$index
index=$(($index + $old_length - $new_length))
if [[ $old_index -lt $CURSOR && $index -ge $CURSOR ]]
then
# $old_index is the start of the last argument.
# you could move back to it.
elif [[ $old_index -le $CURSOR && $index -gt $CURSOR ]]
then
# $index is the start of the next argument.
# you could move forward to it.
fi
# Obviously both of the above conditions could be true, you would
# have to have a way to tell which one you wanted to test - but since
# you will have two widgets (one forward, one back) you can tell quite easily.
done
So far I have shown how you might derive the appropriate index for the cursor to move to. But I have not shown you how to move the cursor, or how to bind these functions to keys.
The $CURSOR variable is able to be updated, and if you do so then you can move the current insert point. Pretty easy!
Binding functions to keys involves an intermediate step of binding to a widget first:
zle -N WIDGET_NAME FUNCTION_NAME
You can then bind the widget to a key. You will probably have to look up the specific key identifiers but I usually just bind to Ctrl-LETTER, which is easy enough:
bindkey '^LETTER' WIDGET_NAME
Lets put all of this together and fix your problem:
function move_word {
local direction=$1
buffer=$BUFFER
words=${(z)buffer}
index=1
old_index=0
for word in $words[@]
do
if [[ ! ${buffer} =~ ${${(q)word}:gs#\\\'#\'#} ]]
then
echo "Something strange happened... no match for current word $word in $buffer"
return 1
fi
old_length=${#buffer}
buffer=${buffer[$MEND,-1]}
new_length=${#buffer}
index=$(($index + $old_length - $new_length))
case "$direction" in
forward)
if [[ $old_index -le $CURSOR && $index -gt $CURSOR ]]
then
CURSOR=$index
return
fi
;;
backward)
if [[ $old_index -lt $CURSOR && $index -ge $CURSOR ]]
then
CURSOR=$old_index
return
fi
;;
esac
old_index=$index
done
case "$direction" in
forward)
CURSOR=${#BUFFER}
;;
backward)
CURSOR=0
;;
esac
}
function move_forward_word {
move_word "forward"
}
function move_backward_word {
move_word "backward"
}
zle -N my_move_backwards move_backward_word
zle -N my_move_forwards move_forward_word
bindkey '^w' my_move_backwards
bindkey '^e' my_move_forwards
As far as my own testing goes, this seems to do the job. You will probably want to change the keys that it binds to. For reference, I tested it with the line:
one 'two three' "four five" "'six seven' eight nine" * **/ **/**/*
^ ^ ^ ^ ^ ^ ^ ^
and it navigated between the carets. It does not wrap.
Best Answer
You can use vim movement commands in readline/bash even while still in emacs movement mode. The relevant readline commands are
vi-fWord
andvi-bWord
. You can bind them to keyboard shortcuts such asCTRL-f
andCTRL-b
with the following in your.bash_profile
:Note that the double quoting is significant.
You can confirm that the bindings are working by running
bind -p