rc shell instead of bash or fish

2024-05-29

Why rc?

About five years ago, I switched from bash to fish because it has many little improvements compared to bash when used as interactive shell and also the language syntax makes more sense to me.

In the end, I wrote only a few scripts in fish because we use bash at work and having to memorize all the details of fish for just a few personal scripts isn't worth it. I rather try to write POSIX shell scripts which are both fast and portable.

Regarding the interactive shell it's different: I can use any shell I like both at home and at work (though I don't bother to install fish in Docker dev containers). And I use the shell a lot. This means that investing some time in getting used to a new shell might be reasonable.

On the one hand, fish comes with many nice features. On the other hand, I have developed a preference for simple programming languages like C, Hare, and Janet and simple software like the dynamic window manager (dwm), the simple terminal (st), pass, aerc, nsxiv.

Among the (Linux) shells, bash, zsh, and fish are the heavyweights (a.k.a. bloat). See the code line count for different shells (which always is only a rough indicator for software complexity):

Bar chart with lines of code per shell

rc in this diagram is Byron Rakitzis' implementation and this is the shell I'm writing about in this article.

To be fair: You can build rc with different line editing libraries. If you pick readline (as I do), you should add 29k lines of code to the 9k lines in the diagram. Then the total line count isn't that far from fish's. But maybe it's possible to get bestline (3k lines of code) integrated into rc. This combination would clearly win in terms of simplicity.

Syntax

While the differences in language syntax don't matter much for interactive shell usage, I want to mention the biggest advantage in rc's syntax: You don't need to quote a variable to preserve spaces in its value:

file = 'Artist - Song.mp3'
cp $file $home/Music

On the other hand, in bash, you can simply quote the result of a command substitution:

file="$(head -n 1 files.txt)" # first line: Artist - Song.mp3

rc will consider $file an array of three elements: Artist, -, and Song.mp3.

file = `{head -n 1 files.txt}
cp $file $home/Music # fails

To prevent this, you can temporarily override $ifs:

file = `` $nl {head -n 1 files.txt}

In this case the bash syntax seems simpler to me.

But you get used to it quickly.

Read Rc — The Plan 9 Shell to learn more about rc's syntax.

What you might miss in rc (with readline)

In general, you can't expect everything being better in simpler software. Usually, the question is: Do I really need the features offered by the more complex software?

Here is a list of things I noticed to be different in rc:

~ for $HOME

After using bash and fish for so long, I'm used to type ~ for $HOME. In rc, ~ doesn't mean $HOME. You write p.e. $home/.vimrc instead. But luckily, readline understands ~. When the cursor is on a path starting with ~, you can enter Alt-~ or Alt-& to expand it. Both are not easy to type and having to do this manually is annoying.

My solution to this is to auto-expand ~ every time I hit tab or enter:

.inputrc:

$if rc
    # expand tilde on tab and enter
    "\C-\xfe": menu-complete
    "\C-i": "\e~\C-\xfe"
    "\C-\xff": accept-line
    "\C-m": "\e~\C-\xff"
$endif

C-i is Tab and C-m is Enter. C-\xfe and C-\xff are just dummy keybindings because readline doesn't allow binding two commands (p.e. tilde-expand plus menu-complete) directly to one key.

Copy current command

Sometimes, I want to copy the current command (buffer) to the clipboard and send it to a colleague or paste it into some document. In fish, this is mapped to Ctrl-x.

In bash, you can write a function like this:

copy_line_to_x_clipboard () {
  printf %s "$READLINE_LINE" | xclip -selection CLIPBOARD
}

and map it to whatever you want.

In rc, you can also write such a function, but I couldn't find a way to bind it to a key.

Tab-completion of arguments

In fish, you can write ssh <Tab> to get a list of available hosts. In bash, you get the same after installing the bash-completion package. In rc, this is not possible, as far as I know.

Multi-line commands

If you want to repeat a three-liner in your interactive rc session, you have to search and repeat every single line one by one.

These means it's better to write one-liners in the first place. Or maybe even a small script (file).

Edit command in editor

Whenever I copy a multi-line command from a README or a webpage which needs some adjustment, I start (Neo)vim via Alt-v. (The default keybinding in bash is Ctrl-x Ctrl-e, in fish it's Alt-e or Alt-v.)

This is not possible in rc as far as I know. As a workaround, I have defined this helper function in my .rcrc:

# edit command in nvim and source it afterwards
fn editcmd {
    tmp_file = `{mktemp /tmp/cmd-XXXXXX.rc}
    nvim $tmp_file && . $tmp_file
}
fn e editcmd

This way, I can start Neovim with e<Enter>, paste and edit a command which will be executed after saving and quitting.

The only disadvantage is that I can't start typing a command and then decide that I want to continue editing it in Neovim.

Syntax highlighting (fish)

Fish's syntax highlighting is nice but not really necessary. Most entries in my shell history are just a command plus a few arguments. For writing/editing non trivial commands, I usually use vim which gives me syntax highlighting for free.

Abbreviations (fish)

fish's abbreviations are like aliases except that they are expanded automatically when you press Space or Enter. This way you see what you get which is nice if you are not sure if an abbreviation means what you think it means.

In bash, you can add this feature via this script: momo-lab/bash-abbrev-alias.

Regarding rc, I don't see a way how to achieve this (except of patching the rc source code).

fzf helpers

You probably know fzf. It's an interactive filter for lists. fzf ships integrations with bash, fish, and zsh which allow you to fuzzy find a path to cd to, a file to pass as argument to a command, or to pick a command from your shell history.

There isn't such an integration for rc. This is my minimal solution for cd:

.rcrc:

FZF_ALT_C_COMMAND='fd --type d --hidden --follow --exclude .git'

fn cdfzf {
    FZF_DEFAULT_COMMAND=$FZF_ALT_C_COMMAND dir = `` $nl {fzf --walker=dir}
    if (!~ $dir ()) cd $dir
}

.inputrc:

$if rc
    # cd into subdirectory selected with fzf
    "\ec": "\C-e\C-ucdfzf\C-m\C-y"
$endif

This even restores your current command line in case you start typing a command and notice then that you are in the wrong directory.

z

rupa/z keeps track of your most used directories, based on frecency (=the both frequently used and recently used directories). It works in bash and zsh. But there is also jethrokuan/z for fish.

It's used like this: When I type z start, it changes the current working directory to ~/.vim/pack/plugins/start (because I have visited this directory more often that other directories containing start in their name).

Unfortunately, these tools don't work in rc because they rely on sourcing code (which usually is incompatible with rc).

Good news: I have migrated z to rc: https://sr.ht/~maxgyver83/z.rc/

It works like the other implementations and even reuses your z history. But it has no argument completion because rc doesn't support this at all. (Please send me an e-mail if I'm wrong about this!)

bass (fish)

When you want to execute an incompatible line of bash code in fish, you can just start bash and paste the line. But sometimes this doesn't help because this line also sets variables. Or you want to source an initialization script, p.e for ROS: source install/setup.bash.

Sometimes, you can run this as a workaround:

exec bash -c "source install/setup.bash; exec fish"

But then you lose your non-exported variables.

For such cases, you can use edc/bass: Make Bash utilities usable in Fish shell.

I couldn't find something similar for rc.