Vterm completion for files, directories, command history and programs in Emacs
Tags: vterm, emacs, linux, tutorial
Published: 2021-08-07 04:24:07

This is a naive solution. Feel free to suggest alternative solutions or
improvements.

https://gist.github.com/ram535/a2153fb86f33ecec587d593c1c5e1623

## The Goal

When pressing `TAB` in vterm terminal, we should get a list of
suggestion. The suggestion list should either be a file, a directory, a
history command or a program name.

## What we need

This is the list of what we need for the solution.

-   emacs
-   vterm
-   bash


## Step 1 - Get the list of program

If you go to the terminal and run:

    compgen -c

You will get a list of all the programs under the PATH environment
variable.

Let's get that list using elisp.

    (shell-command-to-string "compgen -c")
    ;; "emacs\nnano\nneovim\nvim......."

This gives us a long string.

Let's split that long string into a list of string.

    (split-string (shell-command-to-string "compgen -c") "\n" t )
    ;; ("emacs" "nano" "neovim" "vim".......)

Now we have a list of the programs of the system.

## Step 2 - Choose a program from the list of programs

Now we can choose an item from that list of programs using the
`completing-read` build-in emacs function.

    (completing-read "Command " (split-string (shell-command-to-string "compgen -c") "\n" t ))

## Step 3 - Send the chosen program to vterm

    (vterm-send-string
     (completing-read "Command "
                      (split-string (shell-command-to-string "compgen -c") "\n" t )))

## Step 4 - Get the list of files and directories in the CWD

If you go to the terminal and run:

    compgen -f

You will get the list of files and directories in the current working
directory (CWD).

Let's get that list using elisp.

    (shell-command-to-string "compgen -f")
    ;; "Documents\nDownload\nMusic\nProjects......."

This gives us a long string.

Let's split that long string into a list of string.

    (split-string (shell-command-to-string "compgen -f") "\n" t )
    ;; ("Documents" "Download" "Music" "Projects".......)

Now we have a list of files and directories of the CWD.

## Step 5 - Choose a file or directory from the list of files and directories

    (completing-read "Choose " (split-string (shell-command-to-string "compgen -f") "\n" t ))

## Step 6 - Send the chosen file or directory to vterm

    (vterm-send-string
     (completing-read "Choose "
                      (split-string (shell-command-to-string "compgen -f") "\n" t )))

## Step 6.5 - There is a gotcha with getting the list of files and directories

The `default-directory` emacs variable is never update when we move to
different directories in a `vterm terminal`.

That can be solve calling this function.

    (defun vterm-directory-sync ()
      "Synchronize current working directory."
      (interactive)
      (when vterm--process
        (let* ((pid (process-id vterm--process))
               (dir (file-truename (format "/proc/%d/cwd/" pid))))
          (setq default-directory dir))))

The Step 14 has been update with this solution and everything should work as intended.

## Step 7 - Get the list of bash history commands

Bash history commands are save in the `.bash_history` file.

Let's create a temporary buffer and insert the content of
`.bash_history` file into it.

    (with-temp-buffer
      (insert-file-contents "~/.bash_history")
      (split-string (buffer-string) "\n" t))

Let's split the content of the temporary buffer into a list of strings.

    (with-temp-buffer
      (insert-file-contents "~/.bash_history")
      (split-string (buffer-string) "\n" t))

Now we have a list of bash command history.

## Step 8 - Choose a command from the list of bash command history

    (completing-read "History" (with-temp-buffer
                                    (insert-file-contents "~/.bash_history")
                                    (split-string (buffer-string) "\n" t)))

## Step 9 - Send the chosen history command to vterm

    (vterm-send-string
       (completing-read "History" (with-temp-buffer
                                    (insert-file-contents "~/.bash_history")
                                    (split-string (buffer-string) "\n" t))))

## Step 10 - Combine the list of files, directories, history commands and programs into one list

Let's combine the lists we got from step 1, 4 and 7 into one list.

    (let ((program-list (split-string (shell-command-to-string "compgen -c") "\n" t ))
          (file-directory-list (split-string (shell-command-to-string "compgen -f") "\n" t ))
          (history-list (with-temp-buffer
                          (insert-file-contents "~/.bash_history")
                          (split-string (buffer-string) "\n" t))))
    
      (append program-list file-directory-list history-list))

Let's make it a function.

    (defun get-full-list ()
      (let ((program-list (split-string (shell-command-to-string "compgen -c") "\n" t ))
            (file-directory-list (split-string (shell-command-to-string "compgen -f") "\n" t ))
            (history-list (with-temp-buffer
                            (insert-file-contents "~/.bash_history")
                            (split-string (buffer-string) "\n" t))))
    
        (append program-list file-directory-list history-list)))

## Step 11 - Delete duplicates (optional)

We are going to use `-distinct` function from the dash.el package.

    (defun get-full-list ()
      (let ((program-list (split-string (shell-command-to-string "compgen -c") "\n" t ))
            (file-directory-list (split-string (shell-command-to-string "compgen -f") "\n" t ))
            (history-list (with-temp-buffer
                            (insert-file-contents "~/.bash_history")
                            (split-string (buffer-string) "\n" t))))
    
        (-distinct (append program-list file-directory-list history-list))))

## Step 12 - Give suggestion for a partial word

Imagine we type:

    em
      ^

`^` is the position of the cursor. In this scenario we would like to
have a suggestion of items that contain the letters "em".

If we read the documentation of the function `completing-read`.

    (completing-read PROMPT COLLECTION &optional PREDICATE REQUIRE-MATCH INITIAL-INPUT HIST DEF INHERIT-INPUT-METHOD)

We can see that we can give an `INITIAL-INPUT`.

But how do we get an `INITIAL-INPUT`. That is where `thing-at-point`
build-in emacs function comes in handy.

Let's see some examples:

    world
      ^
    
    world
         ^

If we call `(thing-at-point 'word 'no-properties)`, in either example,
it returns "world". Now can use `(thing-at-point 'word 'no-properties)`
as out `INITIAL-INPUT`.

Let's go back to our scenario. We type "em" and evaluate the code below,
it will give us suggestion of words that contain "em" from the the list
we got in the `step 10`.

    (completing-read "Choose: " (get-full-list) nil nil (thing-at-point 'word 'no-properties))

Let's make it a function.

    (defun vterm-completion-choose-item ()
    (completing-read "Choose: " (get-full-list) nil nil (thing-at-point 'word 'no-properties)))

## Step 13 - Replace partial word with the chosen word

First we check if there is a word at point.

    (when (thing-at-point 'word))

If it is a word, let's delete it.

    (when (thing-at-point 'word)
    (backward-kill-word 1))

`WARNING` (backward-kill-word) will not work in vterm. You have to use
`(vterm-send-meta-backspace)` instead.

Let's insert the chosen item in a vterm terminal.

    (defvar vterm-chosen-item (vterm-completion-choose-item))
    
    (when (thing-at-point 'word)
    (vterm-send-meta-backspace))
    
    (vterm-send-string vterm-chosen-item)

Let's make it a function.

    (defvar vterm-chosen-item)
    
    (defun vterm-completion ()
      (interactive)
      (setq vterm-chosen-item (vterm-completion-choose-item))
      (when (thing-at-point 'word)
        (vterm-send-meta-backspace))
      (vterm-send-string vterm-chosen-item))

## Step 14 - Solution
I use general.el for the keybindings and evil-mode.

    (use-package vterm
      :config        
      (defun get-full-list ()
        (let ((program-list (split-string (shell-command-to-string "compgen -c") "\n" t ))
              (file-directory-list (split-string (shell-command-to-string "compgen -f") "\n" t ))
              (history-list (with-temp-buffer
                              (insert-file-contents "~/.bash_history")
                              (split-string (buffer-string) "\n" t))))
    
          (delete-dups (append program-list file-directory-list history-list))))
    
      (defun vterm-completion-choose-item ()
        (completing-read "Choose: " (get-full-list) nil nil (thing-at-point 'word 'no-properties)))
    
      (defun vterm-completion ()
        (interactive)
        (vterm-directory-sync)
       (let ((vterm-chosen-item (vterm-completion-choose-item)))
          (when (thing-at-point 'word)
             (vterm-send-meta-backspace))
          (vterm-send-string vterm-chosen-item)))
    
      (defun vterm-directory-sync ()
        "Synchronize current working directory."
        (interactive)
        (when vterm--process
          (let* ((pid (process-id vterm--process))
                 (dir (file-truename (format "/proc/%d/cwd/" pid))))
            (setq default-directory dir))))
    
      :general
      (:states 'insert
               :keymaps 'vterm-mode-map
               "<tab>" 'vterm-completion))


## Extra

Increase the bash history command and do not store duplicate items.

Add this in the `.bashrc` file.

    export HISTSIZE=10000
    export HISTFILESIZE=10000
    export HISTCONTROL=ignoreboth:erasedups