Objectives
Fish shell is a user-friendly command line shell and scripting langauge. However, the project is relatively new, so it can be somewhat challenging to find concise information on how to use the scripting language.
In this article, we will develop a basic session management tool for (Neo)Vim. The intention of this tool is to be a wrapper around Tim Pope’s obsession.vim plugin, with the following features:
- Easy session initialization: list sessions and open them
- Active session management: we only want to allow a single instance of vim to be using a session file
- Autocompletions: get relevant results when you hit
TAB
Perhaps you simply find the session management tool useful: you can find the program in the Git repository. This implements all the features below, along with a couple extra useful commands, completions, and help messages.
Prerequisites
I will assume that you have the tools fd and fzf installed and accessible in your shell.
If you don’t, you can simply omit the components that use fzf
, and rewrite the parts using fd
to use find
instead.
I will also assume you know a bit about (Neo)Vim, including plugin installation.
Session initialization and management
In this section, we will write the core functionality of our program: save and open session files.
First, let’s create a global variable to represent where we want to save the session files. Add the line
to your config.fish
, or wherever you prefer to define environment variables.
You can set the folder to be anything you want.
Now exec fish
to load this variable.
To ensure that this variable is loaded, you can run
envs | grep ^VS_SESSION_DIR
and check that there is a match.
The command env
prints out all currently defined environment variables—we just search for the line that starts with VS_SESSION_DIR
.
A wrapper for Obsession.vim
Obsession.vim is itself a wrapper around the vim command :mksession
, which creates a session file and saves the current state of vim (e.g. tabs, windows, layout, etc.) to that file.
If you have a session file session.vim
, you can restart it with vim -S session.vim
.
Install this plugin in your vimrc
and also add the function definition
command -nargs=1 VSave Obsess $VS_SESSION_DIR/<args>.vim
This defines a command :VSave
, which takes exactly one argument which is the name of the session file (with no extension).
To begin a new saved session, run :VSave my/session
from an active vim instance.
To terminate, run qa
: note that closing files individually will modify the session file so those files will remain closed on restart.
Starting up existing sessions
Fish functions are defined in the folder ~/.config/fish/functions
and corresponding completions in ~/.config/fish/completions
.
Let’s write the command to start up new sessions, which we will invoke with vs
.
We want to accept the session name as an argument, and then check if the session file exists: if it does, open it; if not, terminate with a nice error message.
In the file ~/.config/fish/functions/vs.fish
, we define a function as follows:
To invoke, just run vs <session name>
, and the corresponding session will be started!
The --argument session_name
automatically assigns the first argument passed to vs
to the variable session_name
.
Note that the variable will be empty if there are no arguments passed to vs
.
Any other arguments are simply ignored.
In shell scripting languages, conditionals often execute based on the return code of a command.
In this case, the command test -f
returns 0 if $sessionfile
exists, and returns something else otherwise.
We redirect the output of echo
to STDERR with >&2
, and then return with the default error code 1.
Note that you can define multiple functions in a single file myfunc.fish
, but fish only knows to load those functions on request if there is a matching filename.
You should only define helper functions for the main function in the file, since they will not be loaded until the main function is called.
You can also edit functions in the current interactive shell with funced <function name>
, and save the functions to the directory with funcsave <function name>
.
However, doing this will remove any helper functions you have defined in the file!
This is also an easy way to introspect fish functions which you may have not defined yourself.
If you edit a function file, sometimes you need to reload your shell for proper execution.
The easiest way to do this is to exec fish
.
A basic subcommand: session listing
For convenience, it would also be nice to be able to get a list of the existing session names.
This utility will also be necessary later, when we provide autocompletion.
Now, since we want multiple behaviours, we will invoke the desired behaviour with two subcommands: we will invoke the previous function with open
, and the new listing function with list
.
First, we define a helper function to list sessions.
Using fd
, we can quickly get a list of candidate files:
fd -e vim --base-directory $VS_SESSION_DIR
However, we only want the name of the session and not the extension .vim
.
The easiest way to do this is to use --exec echo {.}
: {.}
is replaced with the filename with no extension.
This also handles the case where the filename has multiple periods, unlike something more direct such as cut -d "." -f 1
.
We also want to sort the output, since fd
is multithreaded by default when called with --exec
and the order can change each time (which could be confusing).
Wrapping this in a function, we get
We can add this as a subcommand to our original function:
Now, open session files with vs open session/name
and list possibilities with vs list
.
Fancy session selection with fzf
For convenience, let’s also write an interactive file chooser using fzf
This command reads input from STDIN and opens up an interactive browser which allows selection.
Upon choosing an option, the corresponding line is sent to STDOUT.
This variable is captured using fish parameter expansion (...)
and saved in the variable fzf_session
.
Note that if fzf
is terminated early using Ctrl+C
, the variable $fzf_session
will not be saved, so we also need to check that it is non-empty.
Add the following at the beginning of the indentation block directly below case open
:
Essentially, when vs open
is called with no session name, an interactive prompt opens and allows you to search the available sessions.
In fish, quotes are usually not required since variables are passed ‘as atoms’ rather than being expanded and separated on whitespace (as is the case with bash).
One of the few exceptions to this rule is test -n "$var"
, which checks that var
is defined and non-empty.
In this situation, you must always quote the variable since if var is not defined, then $var
will expand to an argument list of length 0, essentially calling test -n
instead of the desired test -n ""
.
Active session management
Now let’s add active session management. If one shell instance has a session file open, we want to prevent another instance from opening up the same session file: multiple writes to the session file could corrupt the file! We will achieve this with file locking. However, a bit of care needs to be taken: we need to handle termination of the script while it is running. The easiest way to do this to use event handlers.
When a function is created, it can be registered as an event handler for certain events.
We care about three events: when the function receives the signal SIGINT
or SIGHUM
, which indicates interruption of the script, or when the shell itself terminates.
Basic event handler example
Consider the following function:
If you run the script normally, following the prompt, the function simply prints
example
Press ENTER to continue [ENTER]
Done!
Cleaning up!
to your terminal.
However, suppose instead of pressing ENTER
, you hit Ctrl+C
to terminate.
Then the __example_cleanup
event runs immediately, and the function will print
example
Press ENTER to continue [Ctrl-C]
Cleaning up!
Even though the function never completed, the cleanup function still fires.
The handler --on-event fish_exit
also catches the case where you, say, close the entire terminal window while the function is running.
Note that we must delete the function __example_cleanup
when we execute it, with functions -e
.
Otherwise, __example_cleanup
will continue to live in our interactive shell and will fire even if we run Ctrl+C
during the execution of a different program.
Incorporating file locking
Our idea is now the following: when we first start up our session, we check for the existence of lock files.
If one does not exist, create it, and start up the session; otherwise, terminate with an error,
For convenience, since the session files are saved as <session name>.vim
, let’s save the lock files as <session name>.lock
.
In order to avoid race conditions, we can create the lock file and test its existence simultaneously using mkdir
:
This works since mkdir
returns error code 1 if the directory it is trying to create already exists.
We also supress the mkdir
error message, since we want to send a more meaningful one to the user ourselves.
We also need to clean up the lockfile on exist using the event handler from the previous section. All together, our code now looks like this
In principle, we might have to worry about multiple instances of the same event handler __vs_cleanup
.
However, as of the time of writing this article, fish does not support background functions, so we only need to worry about our function running in multiple shells, in which case we do not need have this issue.
A note on trap
Fish comes with a function trap
, which is just a wrapper around the event handler method explained above.
At its core, the implementation of trap converts EXIT
into --on-event fish_exit
and all other signals into --on-signal <signal name>
.
You can call trap
directly with the cleanup function (with no handlers attached), like
Note that, in this situation, the event handlers are not automatically deleted. To do this, you need to run
to reset the traps.
To debug issues with handlers persisting longer than you expect, you can get a list of all active handlers with
Finishing up
Starting new sessions
It would also be nice to start new sessions with a terminal in the given directory.
We can tell vim to execute some commands using the +<command>
syntax: such arguments passed to vim will be executed in order as the vim session is started.
For example, if you run
vim +term
you will get a vim terminal pane in the current directory.
Since we already wrote a function VSave
earlier, it’s a simple matter to call this function as well:
vim "+silent VSave <session_name>" +term
The silent
command executes the next command without printing anything into the Vim pane.
We also do a quick check that there is not an existing session with the provided name.
Now, running vs init <session_name>
will create a new session with name <session_name>
and start out with a terminal in the directory where the command was called.
The entire function
It remains to add a case where an invalid command is given, and to print out a short error message.
After doing this, our file vs.fish
now has the following contents:
Here are some other feature ideas:
- custom
rename
anddelete
commands - nicer file listing with
tree
(hint: usetree --fromfile .
to format input from STDIN, andisatty
to only format when the output is a terminal) - add some nice help messages
Autocompletions
Finally, it would be nice to have some autocompletions for our script.
Completion files are stored in a file with the same name as the function file, except in the ~/.config/fish/completions
directory.
We want basic descriptions for the commands when we hit TAB
, and we also want autocompletion for the session name when we call vs open
.
Fish completion files are also just regular lists of functions, except they are loaded when autocompletion for a certain function is requested.
You can read the fish docs about completions if you would like.
We begin by disabling file completion on the base command, which is enabled by default. This is done with the command
This way, when we hit tab, we are not suggested offered files in the current directory in the completion list.
Now, we need to add our subcommands.
This is done as follows: to add the completion option open
, we use
However, this has a problem since now fish will suggest open
as a valid argument at any time, when it should only be valid at the beginning.
In order to fix this, we first introduce a list of all our valid command names with
and then use the -n
flag for complete
to only complete in the case that we have not yet seen any subcommands.
We can also include an option for vs list
.
\
\
\
\
\
\
Finally, when we have seen the subcommand vs open
, we want to provide the valid list of options.
Here, the vs list
command comes in handy:
\
As a whole, the contents of the completion file can be found in the Git repository.
As a whole, the completion file ~/.config/fish/completions/vs.fish
looks like
\
\
\
\
\
\
\
Now hitting vs
SPACE
TAB
prompts with two options: open
, or list
, and hitting vs open
SPACE
TAB
prompts with the possible session names.