Branching out from Bash: Fishing expedition

Given that Apple has already switched the default shell in Catalina from Bash to Zsh, I’ve been thinking I really need to expand from Bash. Don’t get me wrong, I love Bash and I’ve spent years molding it to my liking. The amount of time I’ve put into it and the ease with which that investment allows me to use it has always made switching to anything else seem, well, like a waste of time. But now I feel stuck. To that end, I’ve been stretching out and trying to shake off my fear of getting to know other shells.

I decided to spend some time getting comfortable with Zsh and Fish (the Friendly Interactive SHell). I started with Fish, building a configuration on weekends. Three weekends now and I’m comfortable enough to use it as my regular shell during the week.

Fish features advanced autosuggestion and expansions, does cool syntax highlighting, offers a “sane” scripting toolset, has an array of existing plugins and themes, and even sports a browser-based configuration tool that’s pretty awesome. So here are some random notes from my travels. This is a journal, not a tutorial, containing my impressions and a few tips.

Documentation

The documentation for Fish is excellent, from the overview tutorial to the full shell documentation. And once you have it running, typing help will open a local copy of the documentation in your web browser, and help COMMAND will drill down to docs for any builtin.

Fish circumvents the need for some loading of man pages using autocompletion, too. It scans all of your man pages and determines the options/flags available for most commands, adding them to each command’s autocompletion dictionary.

Porting

First things first, I had to know that some of my most time-saving scripts and lovingly-crafted utilities were not going to die in the process of switching. It turns out that everything works fine with a few shebangs and a little reworking to rely less on executing within the Bash environment. I didn’t have to rewrite any scripts of significant size; at worst I retooled a few 5-line functions, mostly just to get a feel for the scripting language. It really is pretty sane.

Aliases

Almost 100% of my aliases converted painlessly from my .bash_profile to my Fish init files. That said, in Fish the alias command is just a wrapper that creates a function. There are no aliases, per say. One nice feature of functions is that when you run type (actually a wrapper for functions) on a function, it will tell you what file and line it was declared on. So that’s basically the functionality of the complex where system I created for Bash. It doesn’t work with alias, though, because those are declared by the wrapper dynamically. So for my purposes, I did some search and replace in Sublime and just converted my aliases to functions myself. alias xc="open -a Xcode" becomes function xc;open -a Xcode $argv;end.

Side note, using type/functions to display a function in Fish includes comments, unlike Bash. You can also declare a description in the function declaration, making it easy to find out what’s doing what from within the shell.

Here’s my current init file, just for reference. It’s just called brett.fish and loaded from ~/.config/fish/functions, which is an autoload directory, so I don’t have to source it (or any other .fish files in that directory) anywhere else. I’m adding to it as I need to (when I miss something from Bash), so it’s in-progress. I’m also really new at this and probably doing some stuff wrong, so take it with a grain of salt.

Scripts

Converting bash scripts is easy, because you don’t really have to. Just ensure that you have a hashbang1 (#!/bin/bash) at the top of the script and you’ll be able to run it just like you always have. My bigger concern was that I have a lot of files sourced in my Bash config that define functions instead of being external scripts to call. These were easy to port, though.

My Bash functions like reiki and na work fine with little extra effort. I made a ‘fish’ subdirectory in the directory where I keep all of my scripts. In my Fish environment I added that to my path (set -U fish_user_paths /Users/ttscoff/scripts/fish). It contains files named for what would have been function names in my Bash setup, with a #!/bin/bash hashbang. When run, each one just sources its related functions from my bash setup into the script environment and then runs function_name $@ at the bottom. For example, the contents of ~/scripts/fish/r (my reiki command):

#!/bin/bash
. ~/.bash_it/plugins/enabled/reiki.plugin.bash
_r $@

Porting Bashmarks

I’m a big fan of Bashmarks, a tool for bookmarking directories and jumping between my projects. There are a couple equivalent packages for Fish, and after trying them the easiest option seemed to be a plugin called Jump. Whereas Bashmarks uses a .sdirs text file to store all of its shortcuts, Jump uses a ~/.marks directory containing symlinks, named for the bookmark and linking to the target. I wrote a quick script to convert all of my Bashmarks to Jump symlinks, so that can save you some time if you end up going down that road.

Scripting Impressions

Random thoughts…

An expanded test builtin does away with square bracket comparisons, which was never a very intuitive syntax to me, even after it got a bit friendlier in Bash 4. I do wish Fish’s string comparisons handled regular expressions.

Every variable is a “list”, which is a space separated array (with double quotes for items with internal spaces). A variable with a single value is still just viewed as a single-item list. You can reference items using array bracket syntax.

Command substitution is easier but slightly less flexible than in Bash. Instead of $(eval) or using backticks, you just surround the command in bare parenthesis. You might think “but how will I do echo "nested $(command)!",” right? You just have to extract the parens from the quoted string: echo "nested "(command)"!".

Fish has no variable mangling, which I miss frequently. It also doesn’t allow default values (${1:default} in Bash). It’s a bit annoying, but you can simulate default values with a function:

function fallback --description 'allow a fallback value for variable'
  if test (count $argv) = 1
    echo $argv
  else
    echo $argv[1..-2]
  end
end

Then, instead of using ${1:default} as I would in Bash, I can use (fallback $argv "default"). If the $argv is empty, the function only receives one argument (the default) and uses that. If I want my function that opens Finder to the current directory to allow an argument, but fall back to . if I don’t pass one in, I can use:

function f
  open -F (fallback $argv ".")
end

Completions are super easy. Way easier than Bash. The following creates an alias for subl -p and then offers completions of only .sublime-project files in the current directory:

alias sublp="subl -p"
complete -xc sublp -d "Sublime project" -a "*.sublime-project"

Completions can easily use command substitution. This command declares an “alias” for open -a, then offers completions generated by listing all the apps in /Applications and /Applications/Setapp, trimming them to their basename and removing the .app extension.

function o;open -a $argv;end
complete -c o -a (basename -s .app /Applications{,/Setapp}/*.app|awk '{printf "\"%s\" ", $0 }')

There’s a builtin called set_color that makes changing STDOUT color a breeze, so no more ansi escape codes in scripts. It takes color names, and even accepts hex codes if you’re on a 256-color terminal: set_color 333 -b cyan sets the foreground to dark gray in hex and the background to cyan as a named color.

Writing functions in Fish

Just to show the language differences in a simplistic way…

In Bash, my imgsize function:

imgsize() {
    local width height
    if [[ -f $1 ]]; then
        height=$(sips -g pixelHeight "$1"|tail -n 1|awk '{print $2}')
        width=$(sips -g pixelWidth "$1"|tail -n 1|awk '{print $2}')
        echo "${width} x ${height}"
    else
        echo "File not found"
    fi
}

The same function converted for Fish:

function imgsize
  if test -f $argv
    set -l height (sips -g pixelHeight "$argv"|tail -n 1|awk '{print $2}')
    set -l width (sips -g pixelWidth "$argv"|tail -n 1|awk '{print $2}')
    echo "$width x $height"
  else
    echo "File not found"
  end
end

Once you do a few of these, it really doesn’t take much time to modify them.

  • Function declaration is basically the same, but without curly brackets. Just “function…” and “end”.
  • Square bracket comparisons are replaced with the test function, which works in much the same way.
  • There is no $1, $2 for arguments, nor $@ and $*. Only an $argv list variable that you can access as $argv[1], $argv[2], etc. (list indexes are 1-based).
  • Instead of declaring local variables and then setting them, you just set them with the -l switch and do it all in place. You can also use set to make variables global (-g) and universal (-U, shared between all instances and preserved across restarts).
  • No “fi.” Just “end.”

Prompt functions

Update: I’ll leave this section in for posterity, but note that the premise is completely wrong. There’s a way better way to do prompt functions…

Because of the way that Fish handles the prompt function, there’s no easy way to hook it without modifying the original theme files. So that’s what I ended up doing, and I’m unsure if that will bite me next time Oh My Fish updates. But I just had to get my na hook in there so that I see my top todo items for the current project when I cd to its directory.

In my init functions I declare this:

function __should_na
  set -l cmd (history --max=1|awk '{print $1;}')
  set -l cds cd z j popd g
  if contains $cmd $cds
    ~/scripts/fish/na
  end
end

Then in the fish_prompt function for the current theme, I just call __should_na at the very top. It checks to see if the prior command was cd, z (fasd), or g (jump) and if so, runs na for the directory.

Oh My Fish

Much like Oh My Zsh, Oh My Fish is a package and theme manager for the Fish shell. There’s another one called Fisherman, but I haven’t explored it.

There’s a large repository of themes available through Oh My Fish. Most of the Fish themes (including default) use a truncated version of the path in the prompt, and I can not for the life of me find a way to like that. ~/Code/Marked/javascript/dist becomes ~/C/M/j/dist, which does not really tell me which dist folder I’m in without some head scratching and further inspection than I care to give. Some of the themes use a full path, though, and others allow you to toggle short paths on or off. The theme I’ve found myself using most often is bira.

Here are the OhMyFish plugins I’m currently running:

  • argu (sane argument handler)
  • bass (run bash scripts with environment)
  • colored-man-pages (what it sounds like)
  • fasd (autojump-style directory navigation)
  • fzf (fuzzy find selector)
  • git-flow (completion support for git-flow)
  • jump (bashmarks directory bookmarking)
  • osx (integration with finder and itunes)
  • sublime (creates subl command)
  • bang-bang (very basic history substitutions… just !! and !$, but still)
  • brew (integrate homebrew paths into shell)
  • hub (alias and completions for github hub)
  • rvm (Ruby Version Manager wrapper)

There are plenty of others that I played with and didn’t grok, or haven’t had the pleasure yet, so there’s more exploring to do.

That’s where things are at now, we’ll just call this “Fish Journal, First Entry.”

  1. Yes, I say “hashbang” because I’m a gentleman. “shebang” is crude.

Brett Terpstra

Brett is a writer and developer living in Minnesota, USA. You can follow him as ttscoff on Twitter, GitHub, and Mastodon. Keep up with this blog by subscribing in your favorite news reader.

This content is supported by readers like you.