The Valuable Dev

Configuring Zsh Without Dependencies

Huey, Dewey, and Louie with a Z, S, and H t-shirt

This article is part of a series about Zsh:

There are many boring tasks we repeat day after day: creating, copying, moving or searching files, launching again and again the same tools, docker containers, and whatnot.

For a developer, the shell is a precious asset which can increase your efficiency over time. It will bring powerful tools at your fingertips, and, more importantly, it will allow you to automate many parts of your workflow.

To leverage these functionalities, you’ll need a powerful and flexible shell. Today, I would like to present your next best friend: the Z shell, or Zsh.

If you look at the documentation (around 450 pages for the PDF version), Zsh can feel daunting. There are so many options available, it can be difficult to come up with a basic configuration you can build upon.

We’ll build, in this article, a basic Zsh config. I’ll explain the meaning of (almost) everything along the way, including:

  • What’s a Unix shell.
  • Why Zsh is a good choice.
  • How to install Zsh.
  • A brief overview of:
    • Useful environment variables.
    • Aliases.
    • The Zsh options.
    • The Zsh completion.
    • The Zsh prompt.
    • The Zsh directory stack.
  • How to configure Zsh to make it Vim-like.
  • How to add external plugins to Zsh.
  • External programs you can use to improve your Zsh experience.

Are your keyboard ready? Are you fingers warm? Did you stretch your arms? Let’s begin, then!

Brief Unix Shell Overview

A shell interpret command lines. You can type them using a prompt in an interactive shell, or you can run shell scripts using a non-interactive shell.

The shell run just after you logged in with your user. You can imagine the shell as the layer directly above the kernel of Unix-based operating systems (including Linux). Here’s the charismatic Brian Kernighan explaining it casually with his feet on a table.

When you use a graphical interface (or GUI), you click around with your mouse to perform tasks. When you use a shell, you use plain text instead.

If you use a graphical interface (like a windows manager or a desktop environment), you’ll need a terminal emulator to access the shell. In the old days, a terminal was a real device. Nowadays, it’s a program.

The shell gives you access to many powerful programs. They are called CLIs, or Command Line Interfaces.

At that point, you might wonder: why using a shell, instead of a graphical interface?

  • It’s difficult to get a graphical interface right, especially if your software has many functionalities. It can be simpler to build a CLI to avoid some complexity.
  • CLIs are usually faster.
  • A developer deals often with plain text. CLIs are great for that.
  • Many shells, like Linux shells, allow you to pipe CLIs together in order to create a powerful transformation flow.
  • It’s easier to automate textual commands rather than actions on a graphical interface.

Play around with your command shell, and you’ll be surprised at how much more productive it makes you.

The Pragmatic Programmer

A shell is the keystone of a Mouseless Development Environment, and the most powerful tool you can use as a developer.

Speaking of which...
If you want to build a complete Mouseless Development Environment, you might be interested by this book.

Bash vs Zsh

There are other Linux shells available out there, including the famous Bash. Why using Zsh?

  • The level of flexibility and customization of Zsh is crazy.
  • You have access to a powerful completion for your favorite CLIs.
  • The Vi mode is golden for every Vim lovers.
  • There is an important and active community around Zsh.
  • Bash scripts are (mostly) compatible with Zsh.

Bash is simpler than Zsh, but it has also less functionalities.

Zsh Without oh-my-zsh

You’ll see many advising you to install a Zsh framework with a crazy number of plugins, options, aliases, all already configured. The famous ones are Oh My Zsh and prezto.

I tried this approach for years and I think the drawbacks outweigh the benefits:

  • I have no clue what’s included in these frameworks. When I read their documentations, I can’t possibly remember everything it sets. Therefore, I barely use 10% of the functionalities.
  • Zsh has already many functionalities and options, it’s even more daunting to have a framework on top.
  • A framework is a big external dependency which brings more complexity. If there is a conflict with my own configuration or a bug, it can take a long time to figure out what’s happening.
  • A framework impose rules and way of doing I don’t necessarily want, or need.

Don’t get me wrong: these frameworks are incredible feats. They can be useful to get some inspiration for your own configuration. But I wouldn’t use them directly.

Let The Party Begin

We’ll now configure Zsh. If the files or folders I’m speaking about don’t exist, you need to create them.

This configuration was tested with a Linux based system. I have no idea about macOS, but it should work.

Installing Zsh

You can install Zsh like everything else:

  • Debian / Ubuntu: sudo apt install zsh
  • Red Hat: sudo yum install zsh
  • Arch Linux: sudo pacman -S zsh
  • macOS (with brew): brew install zsh

Then, run it in a terminal by typing zsh.

Zsh Config Files

To configure Zsh for your user’s session, you can use the following files:

  • $ZDOTDIR/.zshenv
  • $ZDOTDIR/.zprofile
  • $ZDOTDIR/.zshrc
  • $ZDOTDIR/.zlogin
  • $ZDOTDIR/.zlogout

In case you wonder what $ZDOTDIR stands for, we’ll come back to it soon.

Zsh read these files in the following order:

  1. .zshenv - Should only contain user’s environment variables.
  2. .zprofile - Can be used to execute commands just after logging in.
  3. .zshrc - Should be used for the shell configuration and for executing commands.
  4. .zlogin - Same purpose than .zprofile, but read just after .zshrc.
  5. .zlogout - Can be used to execute commands when a shell exit.

We’ll use only .zshenv and .zshrc in this article.

Zsh Config Path

By default, Zsh will try to find the user’s configuration files in the $HOME directory. You can change it by setting the environment variable $ZDOTDIR.

Personally, I like to have all my configuration files in $HOME/.config. To do so:

  1. I set the variable $XDG_CONFIG_HOME as following: export XDG_CONFIG_HOME="$HOME/.config".
  2. I set the environment variable $ZDOTDIR: export ZDOTDIR="$XDG_CONFIG_HOME/zsh".
  3. I put the file .zshrc in the $ZDOTDIR directory.

Most software will use the path in $XDG_CONFIG_HOME to install their own config files. As a result, you’ll have a clean $HOME directory.

Unfortunately, the file .zshenv needs to be in your home directory. It’s where you’ll set $ZDOTDIR. Then, every file read after .zshenv can go into your $ZDOTDIR directory.

Zsh Basic Config

Environment Variables

As we saw, you can set the environment variables you need for your user’s session in the file $HOME/.zshenv. This file should only define environment variables.

For example, you can set up the XDG Base directory there, as seen above:

export XDG_CONFIG_HOME="$HOME/.config"
export XDG_DATA_HOME="$XDG_CONFIG_HOME/local/share"
export XDG_CACHE_HOME="$XDG_CONFIG_HOME/cache"

You can also make sure that any program requiring a text editor use your favorite one:

export EDITOR="nvim"
export VISUAL="nvim"

You can set some Zsh environment variables, too:

export ZDOTDIR="$XDG_CONFIG_HOME/zsh"

export HISTFILE="$ZDOTDIR/.zhistory"    # History filepath
export HISTSIZE=10000                   # Maximum events for internal history
export SAVEHIST=10000                   # Maximum events in history file

I already explained the first line. For the other ones, they will:

  • Store your command line history in the file .zhistory.
  • Allows you to have a history of 10000 entries maximum.

Here’s my .zshenv file, if you need some inspiration.

Aliases

Aliases are crucial to improve your efficiency. For example, I have a bunch of aliases for git I use all the time. It’s always easier to type when it’s shorter:

alias gs='git status'
alias ga='git add'
alias gp='git push'
alias gpo='git push origin'
alias gtd='git tag --delete'
alias gtdr='git tag --delete origin'
alias gr='git branch -r'
alias gplo='git pull origin'
alias gb='git branch '
alias gc='git commit'
alias gd='git diff'
alias gco='git checkout '
alias gl='git log'
alias gr='git remote'
alias grs='git remote show'
alias glo='git log --pretty="oneline"'
alias glol='git log --graph --oneline --decorate'

I like to have my aliases in one separate file (called, surprisingly, aliases), and I source it in my .zshrc:

source /path/to/my/aliases

Here are all my aliases.

Zsh Options

You can set or unset many Zsh options using setopt or unsetopt. For example:

setopt HIST_SAVE_NO_DUPS         # Do not write a duplicate event to the history file.
unsetopt HIST_SAVE_NO_DUPS       # Write a duplicate event to the history file

You can already do a lot of customization only using these options.

Zsh Completion System

The completion system of Zsh is one of its bigger strength, compared to other shells.

To initialize the completion for the current Zsh session, you’ll need to call the function compinit. More precisely, you’ll need to add this in your zshrc:

autoload -U compinit; compinit

What does it mean?

The autoload command load a file containing shell commands. To find this file, Zsh will look in the directories of the Zsh file search path, defined in the variable $fpath, and search a file called compinit.

When compinit is found, its content will be loaded as a function. The function name will be the name of the file. You can then call this function like any other shell function.

Tip
What about the semi-colon ;? It’s just a handy way to separate commands. It’s the same as calling compinit on a new line.

Why using autoload, and not sourcing the file by doing source ~/path/of/compinit?

  • It avoids name conflicts if you have an executable with the same name.
  • It doesn’t expand aliases thanks to the -U option.
  • It will load the function only when it’s needed (lazy-loading). It comes in handy to speed up Zsh startup.

Then, let’s add the following;

_comp_options+=(globdots) # With hidden files
source /my/path/to/zsh/completion.zsh

The first line will complete dotfiles.

The second line source this file. It’s my personal config for the Zsh completion. I’ve written an article about that if you’re interested to dive more into the completion system.

Now, the completion should work:

  • If you type cp and hit the tab key, you’ll see that Zsh will complete the command.
  • If you type cp - and hit the tab key, Zsh will display the possible arguments for the command.
Zsh completion in action

Pimp My Zsh Prompt

What would be the shell experience without a nice prompt? Dull. Tasteless. Depressing.

Let’s be honest here: Zsh default prompt is ugly. We need to change it, before our eyes start crying some blood. My needs are simple:

  • The prompt needs to be on one line. I had display problems with two lines.
  • The prompt needs to display some git info when necessary.

From there, I created my own prompt from another one. It looks like that:

Zsh prompt

If you open the prompt script, you’ll see that it’s pretty simple:

  • I set two environment variables: $PROMPT and $RPROMPT. The first one format the left prompt, the second display git information on the far right.
  • You can add some formatting styles using, for example, %F{blue}%f to change the color, or %Bmy-cool-prompt%b to make everything bold.

This prompt doesn’t need any external dependency. You can copy it right away and modify it as much as you want.

Here’s everything you need, to create the prompt of your dream.

To load the prompt, you need to add something like that in your zshrc:

fpath=(/my/path/to/zsh/prompt $fpath)
autoload -Uz name_of_the_prompt_file; name_of_the_prompt_file

The first line will add the folder containing the prompt to $fpath, as discussed above. It will also ensure that any function declared in the folder /my/path/to/zsh/prompt will overwrite every other ones with the same name, in other fpath folders.

The second line autoload the prompt itself.

This prompt require font awesome 4 for the git icons. You can download the font and install it, or you can change the icons.

Zsh Directory Stack

Zsh has commands to push and pop directories on a directory stack.

By manipulating this stack, you can set up an history of directory visited, and be able to jump back to these directories.

First, let’s set some options in your .zshrc:

setopt AUTO_PUSHD           # Push the current directory visited on the stack.
setopt PUSHD_IGNORE_DUPS    # Do not store duplicates in the stack.
setopt PUSHD_SILENT         # Do not print the directory stack after pushd or popd.

Then, you can create these aliases:

alias d='dirs -v'
for index ({1..9}) alias "$index"="cd +${index}"; unset index

What does it do?

  • Every directory visited will populate the stack.
  • When you use the alias d, it will display the directories on the stack prefixed with a number.
  • The line for index ({1..9}) alias "$index"="cd +${index}"; unset index will create aliases from 1 to 9. They will allow you to jump directly in whatever directory on your stack.

For example, if you execute 1 in Zsh, you’ll jump to the directory prefixed with 1 in your stack list.

You can also increase index ({1..9}) to index ({1..100}) for example, if you want to be able to jump back to 100 directories.

For example, you can do that:

~ > cd .config
~/.config > cd devdash
~/.config/devdash > cd ..
~/.config > cd i3
~/.config/i3 > cd ..
~/.config > d
0       ~/.config
1       ~/.config/i3
2       ~/.config/devdash
3       ~
~/.config > 2
~/.config/devdash > 

Zsh By Default

When you’re ready psychologically to set Zsh as your default shell, you can run these commands:

  • For Linux: chsh -s $(which zsh)
  • For macOS: sudo sh -c "echo $(which zsh) >> /etc/shells" && chsh -s $(which zsh)

A good soul on Reddit whispered me that Zsh is now the default shell from macOS Catalina onwards, so you don’t necessarily need the above command.

Zsh is now part of your life. Congratulation!

Zsh With Vim Flavors

For editing purposes, Vim is my best friend. I love when CLIs use some Vim key binding, and Zsh gives you even more than that. If you’d like to learn Vim, this series of articles can help.

Activating Vi Mode

Zsh has a Vi mode you can enable by adding the following in your .zshrc:

bindkey -v
export KEYTIMEOUT=1

You can now switch between INSERT and NORMAL mode (called also COMMAND mode) with the ESC key, and use the familiar Vim keystrokes to edit what you’re typing in your shell prompt. I write the different modes in uppercase here for clarity, but it doesn’t have to be.

The second line export KEYTIMEOUT=1 makes the switch between modes quicker.

Changing Cursor

A visual indicator to show the current mode (NORMAL or INSERT) could be nice. In Vim, my cursor is a beam | when I’m in INSERT mode, and a block when I’m in NORMAL mode. I wanted the same for Zsh.

You can add the following in your zshrc, or autoload it from a file, as I did.

cursor_mode() {
    # See https://ttssh2.osdn.jp/manual/4/en/usage/tips/vim.html for cursor shapes
    cursor_block='\e[2 q'
    cursor_beam='\e[6 q'

    function zle-keymap-select {
        if [[ ${KEYMAP} == vicmd ]] ||
            [[ $1 = 'block' ]]; then
            echo -ne $cursor_block
        elif [[ ${KEYMAP} == main ]] ||
            [[ ${KEYMAP} == viins ]] ||
            [[ ${KEYMAP} = '' ]] ||
            [[ $1 = 'beam' ]]; then
            echo -ne $cursor_beam
        fi
    }

    zle-line-init() {
        echo -ne $cursor_beam
    }

    zle -N zle-keymap-select
    zle -N zle-line-init
}

cursor_mode

You can now speak about beams and blocks with passion and verve.

Vim Mapping For Completion

To give Zsh more of a Vim taste, we can set up the keys hjkl to navigate the completion menu.

First, add the following to your zshrc:

zmodload zsh/complist
bindkey -M menuselect 'h' vi-backward-char
bindkey -M menuselect 'k' vi-up-line-or-history
bindkey -M menuselect 'l' vi-forward-char
bindkey -M menuselect 'j' vi-down-line-or-history

We load here the Zsh module complist. Modules have functionalities which are not part of the Zsh’s core, but they can be loaded on demand. Many different modules are available for your needs.

Here, the module complist give you access to the keymap menuselect, to customize the menu selection during completion, including how to select what you want.

In general, the command bindkey -M bind a key to a specific keymap. A keymap is a set of keystrokes bind to specific Zsh functions. In this case, the keymap menuselect bind keystrokes with selecting something in a list.

To list all the keymaps available (depending on the modules you’ve loaded), you can run in your shell bindkey -l (for list). You can also find the default ones here.

Last thing: you should always load the module zsh/complist before autoloading compinit.

Editing Command Lines In Vim

Good news: you can use your favorite editor to edit the commands you’re typing in your prompt! Let’s add these lines in your .zshrc to do so:

autoload -Uz edit-command-line
zle -N edit-command-line
bindkey -M vicmd v edit-command-line

Here, we autoload edit-command-line, a function from the module zshcontrib, which includes many contributions from Zsh users. This specific function let you edit a command line in your visual editor, defined by the environment variable $VISUAL (or $EDITOR). Great! That’s what we wanted.

We already saw bindkey -M. Using the keymap vicmd, we can bind commands to some NORMAL mode keystrokes. It means that, when you’re in NORMAL mode, you can hit v to directly edit your command in your editor.

Adding Text Objects

If you use the Vi-mode of Zsh for a while, you’ll notice that there are no text objects for quotes or brackets: impossible to do something like da" (to delete a quoted substring) or ci( (to change inside parenthesis). Zsh supports these, you just need to generate and bind them to specific Zsh widgets:

autoload -Uz select-bracketed select-quoted
zle -N select-quoted
zle -N select-bracketed
for km in viopp visual; do
  bindkey -M $km -- '-' vi-up-line-or-history
  for c in {a,i}${(s..)^:-\'\"\`\|,./:;=+@}; do
    bindkey -M $km $c select-quoted
  done
  for c in {a,i}${(s..)^:-'()[]{}<>bB'}; do
    bindkey -M $km $c select-bracketed
  done
done

If you want to know more about Zsh widgets, I’ve written another article about that, where I also explain the code above.

Surrounding

Zsh also allows us to mimic the famous Tim Pope’s surround plugin. Just add the following to your zshrc:

autoload -Uz surround
zle -N delete-surround surround
zle -N add-surround surround
zle -N change-surround surround
bindkey -M vicmd cs change-surround
bindkey -M vicmd ds delete-surround
bindkey -M vicmd ys add-surround
bindkey -M visual S add-surround

You can then use cs (change surrounding), ds (delete surrounding), ys (add surrounding) in Zsh’s NORMAL mode.

Zsh Plugins

The term “plugin”, as I use it, has nothing official. People often speak about Zsh plugins as external pieces of configuration you can add to your own.

There are many of these plugins available for Zsh. Many of them are part of Zsh frameworks.

Zsh Completions

By default, Zsh can complete already many popular CLIs like cd, cp, git, and so on.

The plugin zsh-completions add even more completions. The list of the newly supported CLIs is here

If you don’t use any of the program listed, you don’t need this plugin.

I added zsh-completion as a git submodule in my dotfiles. Then, you can automatically add every completion to your fpath, in your zshrc:

fpath=(/path/to/my/zsh/plugins/zsh-completions/src $fpath)

You don’t need to load every completion file, one by one. If you look at the beginning of one of these files, you’ll see compdef. It’s a function from Zsh which load automagically the completion when it’s needed. The completion file itself only needs to be included in your fpath.

You can also cherry-pick the specific completions you want.

Zsh Syntax Highlighting

What about syntax highlighting in Zsh? That’s what zsh-syntax-highlighting is about.

You can source it directly:

source /path/to/my/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

There is one minor downside however: it seems to be currently incompatible with the surround widget we’ve seen above. If you want to use both, you need to use the branch feature/redrawhook.

You should source this plugin at the bottom of your zshrc. Everything loaded before will then be able to use syntax highlighting if needed.

Jumping To A Parent Directory Easily

Do you like to type cd ../../.. to come back to the great-grand-parent of the current folder?

Me neither.

It’s where bd can help you. Imagine that you’re in the folder ~/a/b/c/d. You can jump directly to a with the command bd a.

The Zsh completion is even included. Awesomeness!

To use it, you need to source the file bd.zsh.

Custom Scripts

Using a shell allows you to automate many parts of your workflow with shell scripts. That’s a huge benefit you should take advantage of.

I keep most of my scripts in one file and I document them (roughly) for me to remember what’s in there, and for others to get inspired.

I source the functions in my .zshrc, but you could autoload them too.

While working, ask yourself what tasks you do again and again, to automate them as much as you can. This is the real power of the shell, and it will make your whole workflow more fun.

External Programs

A shell without CLIs would be useless. Here are my personal favorites to expand Zsh functionalities.

Multiplex Your Zsh With tmux

I’ve already written about tmux here. It’s a terminal multiplexer with a tonne of functionalities: you can split your terminal in many windows or panes, synchronize them, and keep your sessions alive even without terminal. You can even extend it with plugins helping you automating your whole shell workflow.

Fuzzy Search With fzf

The fuzzy finder fzf is a fast and powerful tool. You can use it to search anything you want, like a file, an entry in your command line history, or a specific git commit message.

I wrote (or copied and pasted) a bunch of scripts using zsh too, to search through git logs or tmuxp projects.

There are different ways to install fzf. You’ll need first the executable. Then, I would recommend sourcing the files:

  • key-bindings.zsh, which will include some practical keystrokes like Ctrl-h or Ctrl-t
  • completion.zsh, for fzf completion.

If you use Arch Linux, you’ll need to install the package fzf and simply source these two files in your zshrc:

source /usr/share/fzf/completion.zsh
source /usr/share/fzf/key-bindings.zsh

Otherwise, you’ll need to follow the installation process from fzf’s README file.

The Z-Shell Is Now Yours

You should now have a clean and lean Zsh configuration, and you should understand enough of it to customize it.

What did we learn with this article?

  • Zsh reads its configuration files in a precise order.
  • You can set (or unset) many Zsh options depending on your needs.
  • The completion system of Zsh is one of its best feature.
  • Zsh directory stack allow you to jump easily in directories you’ve already visited.
  • If you like Vim, Zsh allows you to use keystrokes from the Vim world. You can even edit your commands directly in Vim.
  • External plugins can be found on The Internet, to improve even further the Zsh experience.
  • You should go crazy on shell scripting, to automate your workflow as much as you can.
  • External programs can enhance your experience with the shell, like tmux or fzf.

All your colleagues will be jealous. Guaranteed.

Share Your Knowledge