Way to move cursor by arguments in bash

bashreadlineshell

In bash, you can use M-f and M-b to move the cursor one word forward and backwards, but is there a way to move one argument forward or backwards? If not out of the box, perhaps by some configuration?

In other words, I would like to cursor to go navigate between the marked positions below.

cp "foo bar.txt" "/tmp/directory with space"
^  ^             ^
|  |             |

Best Answer

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.

Related Question