Ne v kontakte Asocial programmer's blog

Learning Elvish (but not the Middle-earth one)

There is some part of me that is drawn to obscure, odd technologies and tools. It’s kind of irrational, but also very exciting to tinker with. As I was looking for alternatives to my zsh setup, I couldn’t help but look at some of the more niche shells, even if it wasn’t very likely that I’ll settle on them.

Elvish was one of the options that caught my attention with its friendly website, not too stuffy documentation and being written in Go. It’s a non-POSIX shell, which offers some unusual TUI features and a scripting language with some very interesting ideas.

This post is my subjective impressions of it, which you are welcome to consume with some salt to taste. Shell usage tends to be a very personal thing, so take my opinions as a prompt for your own exploration, rather than a final verdict.

elvish.jpg
This is me getting very excited about a new toy.

💻 Interactive usage

One of the main ideas in Elvish TUI is that it has multiple different modes, a bit like vim.

  • insert and command have to do with entering and editing the command line.
  • completion is exactly what you think it is, it gets activated when completing commands and arguments and such.
  • navigation allows you to browse the file system in a more visual way than the traditional cd and ls. It reminded me of my early days of Linux when I heavily used Midnight Commander and would definitely be appreciated by folks who want to use a shell, but are put off by its lack of visual context. location goes together with it, allowing you to jump into recently visited directories.
  • history, histlist and lastcmd provide you different ways to recall your command history.

What’s nice about it, is that all those modes behave intuitively out of the box. You don’t need a 1000-line config and a dozen plugins to hammer them into something sensible, it just works. For those of us with the bash muscle memory, use readline-binding is all you need to keep your habits, and if you do want to customize the behavior, shortcuts and triggers, it’s all available and nicely documented.

The only notable major thing that doesn’t “just work” is completion for well-known commands like git or make. You can find community-written modules that provide all that and more, but it doesn’t come bundled with the shell. Installing third-party modules is a topic deserving its own discussion, I’ll come back to that. Writing your own completion rules is also pretty straightforward.

Performance-wise it’s very snappy. When I first mentioned Elvish in the DevZen podcast, some folks in the audience mentioned that it was very slow for them, taking seconds to start up, but for me that wasn’t an issue at all. The only gotcha was that when I ran it in WSL2, by default $PATH inherited all the paths from Windows, which not only produced a lot of junk suggestions, but also was very slow to enumerate due to how WSL accessed Windows directories. That’s not Elvish’s fault, though, and easy enough to fix, so I only mention it in case someone else runs into the same problem.

An interesting performance feature is stale prompt: if your prompt rendering function takes too long to complete, after a short timeout Elvish will simply show the most recent version of the prompt, and asynchronously re-render it once the function completes. That can be nice if you want to show things like VCS status, which can be somewhat slow on large repos, but don’t want to deal with the latency. Frameworks like oh-my-zsh have to go to significant lengths to work around it, but Elvish makes it trivial.

The one feature that I wanted, but found missing is transient prompt. It’s almost there: you can configure Elvish to clear RPrompt before it executes the command, but it won’t re-render the main prompt. It wouldn’t be too hard to implement, but it’s not there yet. @xiaq is also working on the major TUI rewrite which should make such things easy to implement, but the timeline is “whenever it’s done” (fair enough).

Another feature I wished for is support for background processed. It’s pretty common for me to open vim, do some editing, press Ctrl-Z to put it in the background, run some commands, and go back to vim with fg, but Elvish doesn’t support that. It’s not the end of the world and is easily mitigated by tmux or simply opening another terminal tab, but it breaks my muscle memory enough to be uncomfortable.

🛠 Customization and modules

It’s pretty good! You can customize your prompt however you want by overriding $edit:prompt and $edit:rprompt functions, and the stale prompt feature I mentioned allows you to worry less about performance.

This is what my prompt looks like when ported to Elvish, pretty readable.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{ # Prompt setup
  var prompt-tag = (
    str:replace '%{' '' ^
      (str:replace '%}' '' ^
        (~/git/dotfiles/home/zsh_custom/colors.py)))
  var userhost = (whoami)@(hostname)
  set edit:prompt = {
    print '╭─'(styled $userhost green)' '$prompt-tag' '(styled (tilde-abbr $pwd) blue)"\n"
    print '╰─❯ '
  }

  set edit:rprompt = {
    if (not (eq $selected-go $nil)) {
      put (styled 'go'$selected-go cyan)
    }
  }
}

You can also change the keybindings for each or all modes to your liking. The only change I felt the need to make is disabling “end of history” message when you press the down arrow while already at the most recent command:

1
set edit:insert:binding[Down] = { } # Suppress "end if history" messages.

What really impressed me is that Elvish comes with its own package/plugin manager epm out of the box. This is fantastic because not only it spares me agonizing over which community-provided plugin manager to choose, but it also means that all community packages will work with it. Because there’s only one.

Overall, it’s pretty clearly inspired by go get and it just works. So all those missing completion rules are just one epm:install command away, and https://github.com/elves/awesome-elvish readily offers you the most commonly used ones.

Unfortunately, epm doesn’t seem to support installing a specific version of a package, it always pulls the most recent one. Arguably, this is fine for most people, but personally I prefer to audit all the code that my shell gets to execute, so being able to review a specific version of a package and then stick to it is pretty important for me. This is one of the bigger reasons I wanted to move away from batteries-included frameworks like oh-my-zsh, in favor of something lighter-weight and self-contained. I think it wouldn’t be hard to send a PR adding it, although I haven’t asked.

📜 Scripting language

This is, arguably, Elvish’s most interesting side. I already mentioned it’s not POSIX-compatible, but that also allows it to eliminate a lot of pitfalls you would face with regular bash scripts. Despite some peculiarities, it wins in readability over bash any day. I will leave the job of describing the language in all details to the official documentation, and just call out a few things that I liked in particular.

Functions don’t have return values. Instead, they have structured and unstructured output streams, which Elvish calls byte outputs and value outputs respectively. Byte outputs are basically the usual stdin/stdout I/O we are used to in the traditional shells, and you can build pipelines with them:

1
2
$ echo "Hello, world!" | sed -e 's/world/universe/'
Hello, universe!

Value outputs allow to pass streams of complex types like lists and maps between functions without having to rely on fragile conventions like “one value per line”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ var a = "Hello, world!"  # Plain string.
$ var b = ["first" "second"]  # List with 2 elements.
$ var c = [&who=(whoami) &where=(hostname) &1=42]  # Map with three entries.
$ put $a $b $c
'Hello, world!'
[first second]
[&1=42 &where=MIGHTY &who=nevkontakte]
$ put $a $b $c | each {|val| echo Got: $val[1] }
Got: e
Got: second
Got: 42

In Elvish, put plays a role similar to echo, except that it outputs the structured value, instead of dumping plain bytes into stdout. Needless to say, this makes processing lists of structured data (say, login/host pairs) much nicer. Nushell is built around a similar concept, but dials it up to eleven.

Built-in each and peach functions execute a lambda function over their value inputs sequentially or in parallel, which is orders of magnitude nicer than the traditional xargs.

Modules is another feature that traditional shells invent ad-hoc (and usually awkwardly), which Elvish has built-in. It comes with namespacing and makes organizing your code and third-party modules easy and unambiguous. It also comes with a small standard library of modules that provide built-ins for common tasks like string manipulation, OS interactions and so on. This is actually really handy for writing shell scripts that work both on Windows and *NIX systems.

To give myself a more practical impression of the language, I wrote a module that allows me to switch between multiple Go versions.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
use str
use os
use path

fn remove-path {|path|
  set paths = [(keep-if {|entry| !=s $entry $path } $paths)]
}

fn add-path {|&prepend=false &eval-symlinks=true path|
  if $eval-symlinks {
    set path = (os:eval-symlinks $path)
  }
  remove-path $path  # Prevent duplicates
  if $prepend {
    set paths = [$path $@paths]
  } else {
    set paths = (conj $paths $path)
  }
}

fn in-home-path {|path|
  str:has-prefix $path $E:HOME
}

fn system-paths {
  keep-if {|p| not (in-home-path $p) } $paths
}

if (os:exists /usr/local/go/bin/go) {
  add-path &prepend=true &eval-symlinks=false /usr/local/go/bin
}

if (os:is-dir ~/.cargo/bin) {
  add-path &prepend=true ~/.cargo/bin
}

var selected-go = $nil
if (has-external go) {
  fn govar {|name|
    if (has-env $name) {
      get-env $name
    } else {
      put (go env $name)
    }
  }

  fn gobin {
    var path = (govar GOBIN)
    if (==s $path '') {
      put (path:join (govar GOPATH) "bin")
    } else {
      put $path
    }
  }
  set paths = (conj $paths (gobin))

  fn remove-go {
    var tool-path = (path:dir (os:eval-symlinks (search-external go)))
    if (in-home-path $tool-path) {
      remove-path $tool-path
    }
    if (has-env GOROOT) {
      unset-env GOROOT
    }
    set selected-go = $nil
  }

  fn find-go {
    var versions = [&]

    fn register {|tool| {
      var v = (str:trim-prefix [(str:fields ($tool version))][2] "go")
      var mul = 10000
      var index = (+ (
        str:split "." $v ^
        | each {|n| num $n } ^
        | each {|n| put (* $n $mul); set mul = (/ $mul 100) }
      ))
      set versions[$v] = [
        &tool=$tool
        &index=$index
      ]
    }}

    with paths = [(system-paths)] {
      if (has-external go) {
        register (search-external go)
      }
    }
    for tool [~/sdk/go*/bin/go] {
      register $tool
    }
    put $versions
  }

  fn activate-go {|version|
    var versions = (find-go)
    if (has-key $versions $version) {
      var v = $versions[$version]
      add-path (path:dir $v[tool])
      set-env GOROOT ($v[tool] env GOROOT)
      set selected-go = $version
      edit:redraw &full=$true
    } else {
      fail "No Go "$version" found."
    }
  }

  set edit:completion:arg-completer[activate-go] = {|@args|
    var n = (count $args)
    if (!= $n 2) {
      return
    }

    var versions = (find-go)
    keys $versions ^
      | each {|k|
        put [
          &index=$versions[$k][index]
          &value=$k
        ]
      } ^
      | order &key={|item| put $item[index] } &reverse=$true ^
      | each {|item| put $item[value]}
  }
  edit:add-var activate-go~ $activate-go~
  edit:add-var remove-go~ $remove-go~
}

There is an official VSCode extension, which provides syntax highlighting and even rudimentary autocompletion, and several community-maintained plugins for other editors.

🤝 Community and development

All technical features aside, the community around the project is one of the major factors when I’m deciding whether to adopt a tool for daily use. The most excellent tool without a community and support is more of a liability than a help.

Elvish has been around for a while, and it has a nice, even if a small community. Community chat can be accessed through several different platforms (all cross-communicating with each other) and its regulars will happily provide advice and pointers for your problems.

It also has a remarkably accessible and comprehensive documentation, which equips you with almost everything you would ever need to know to be a happy user.

There are community-provided modules that address much of what one would wish for in their daily-driver shell: https://github.com/elves/awesome-elvish. Among them are some really neat ones like aca/elvish-bash-completion, which can import bash completions, or tesujimath/bash-env-elvish massively simplifies adapting bash-specific tools like nvm to work with Elvish.

I wish a lot of that came out of the box, but I also entirely understand @xiaq’s reluctance to maintain all that in the core repository. Which is a segue to how Elvish is developed and maintained.

It’s written in Go, which was a big plus for me personally. I know and use Go pretty much all the time, which means that installing Elvish is pretty easy for me (even ignoring pre-built binaries), I can use it on any OS, and should I run into a particularly annoying bug, I would have a pretty good chance to fix it myself. In fact, while I was experimenting with it, on several occasions I went poking around the source code to figure out why a built-in function wasn’t doing what I thought it should be doing, and it was pretty easy to find my answers. Hat tip for a clean and well-organized code base.

As far as I could gather, it is essentially @xiaq’s passion project, who is the only and final authority on the direction of the development:

The only person with direct commit access is the project’s founder @xiaq.

docs/contributing.md

Third-party contributions are often accepted, but large changes are usually handled by @xiaq, as much as their spare time permits.

This is an entirely valid way to run an open source project, but it does create a bottleneck in how quickly Elvish can develop and improve. Forking, of course, is always an option, but then I would become my own bottleneck 😅

🏁 Final impression

I really, really liked Elvish and enjoyed tinkering with it 🧶😻

I won’t be using it as a daily-driver shell, at least in the foreseeable future, but this is more about my highly specific wishes than a criticism of the project as such:

  • It is a bit too niche. I want to use the same shell at work and home, and it would require more effort than I’m willing to expend to make all the work tools work for me with Elvish.
  • It offers very nice UX out of the box, but lacks in integrations like completion rules for common tools. For comparison, Fish ships with over a thousand completions out of the box.
  • If I have to trust in community members for essential stuff like completion rules, I’d like to be able to put my trust into one specific version of a module, rather than indefinitely trusting that all future changes to the repository would be benign. If epm supported version pinning it would be an ideal solution, but alas.

That said, if I find myself in a need of a cross-platform shell scripting language (and don’t wish to deal with Python), Elvish remains a pretty nice pick thanks to its OS-independent standard library.

Hat tip to @xiaq for creating and maintaining such a cool project, and to all the awesome community members who contribute to it!