Elvish is a recent attempt at creating a better shell.
I briefly tried it as a shell, and I instantly hated it, but that's possibly because default settings need adjusting. Anyway, complaints from the first few minutes of interactive use:
- Ctrl-A and Ctrl-E don't work
- typing name of a directory doesn't go to that directory (autocd mode)
- tab completion wouldn't work from any part of the name like
cd elvish<TAB>
wouldn't expand tocd episode-48-elvish
, it only works with start of the name - there's no smart autocompletion at all, like
git <TAB>
has no idea whatgit
even is - documentation for customizing the shell is nonexistent
- it was also quite unclear where the hell it even keeps the data on OSX (apparently
~/.local/state/elvish/db.bolt
for history file,~/.config/elvish/rc.elv
for its RC file) - history file is some kind of binary in nonstandard format (not even SQLite), which is really inconvenient
- it doesn't support
alias
etc., so I cannot easily setup RC file by just copying by.zshrc
and adjusting things a bit - how do I even customize prompt? default is bad, and there's no documentation again
- it doesn't seem to support redirection from commands like
diff <(fizzbuzz.elv) <(fizzbuzz.py)
Anyway, I'm not here to evaluate it for interactive use. These issues are likely solvable with better documentation and maybe better presets or migration script.
Mainly I want to see how well it's doing as a programming language for shell scripts.
You can install it from brew install elvish
and starting elvish
from whichever shell you're using. This way it will inherit most environmental settings like $PATH
, $EDITOR
etc. from your normal shell.
Overall documentation is very poor. Many things have some definition but no examples, many things don't have any explanation at all. I needed to figure things out by experimenting, so I might have missed something in this episode due to that. Of course for its excuse, Elvish is not 1.0 yet, and documenting things is hard.
Hello, World!
#!/usr/bin/env elvish
echo "Hello, World!"
We can run it from a file:
$ ./hello.elv
Hello, World!
And of course we can run it interactively from Elvish shell:
$ echo "Hello, World!"
Hello, World!
JSON
Elvish wants to live in a world of proper data structures, but Unix commands work with either lines of text, or just one big unstructured blob of text.
To get some interoperability going, Elvish uses to-json
and from-json
commands. These don't convert to and from JSON document, they convert to and from JSON streams.
$ echo '{"name": "Alice", "surname": "Smith"}' | from-json | each {|x| echo "Hello, "$x[name]"!" }
Hello, Alice!
Weirdly there's no string interpolation in double quotes like in other shells - you need to unquote, put the expression, and resume quoting.
$ var catfacts = (curl -s 'https://cat-fact.herokuapp.com/facts' | from-json)
$ for fact $catfacts { echo $fact[text] }
Wikipedia has a recording of a cat meowing, because why not?
When cats grimace, they are usually "taste-scenting." They have an extra organ that, with some breathing control, allows the cats to taste-sense the air.
Cats make more than 100 different sounds whereas dogs make around 10.
Most cats are lactose intolerant, and milk can cause painful stomach cramps and diarrhea. It's best to forego the milk and just give your cat the standard: clean, cool drinking water.
Owning a cat can reduce the risk of stroke and heart attack by a third.
FizzBuzz
The FizzBuzz isn't too difficult:
#!/usr/bin/env elvish
# FizzBuzz in Elvish
fn fizzbuzz {|n|
if (== (% $n 15) 0) {
echo "FizzBuzz"
} elif (== (% $n 5) 0) {
echo "Buzz"
} elif (== (% $n 3) 0) {
echo "Fizz"
} else {
echo $n
}
}
seq 1 100 | each {|n| fizzbuzz $n}
You probably know what FizzBuzz does by now, so let's go through the code:
-
#
for comments as usual -
seq 1 100
is a standard Unix builtin command, printing numbers 1 to 100 on separate lines - you can pipe it into
| each
, that processes things line by line normally - there's a Ruby-style block syntax, like
{|variables| code}
-
fn name {|arguments| code}
is a function definition -
if
/elif
/else
syntax looks totally reasonable - surprisingly for a shell, math uses Lisp style prefix notation. This actually makes a lot of sense, as in shell first thing is usually command and rest are arguments anyway, so Elvish just interpreted parentheses as a subcommand
Fibonacci
Let's try to write a Fibonacci function in Elvish:
#!/usr/bin/env elvish
fn fib {|a b|
echo $a
fib $b (+ $a $b)
}
fib 1 1
It keeps printing forever, but that's totally fine, as it's shell, so we can just | head -n 5
, right? Well...
$ ./fib.elv | head -n 5
1
1
2
3
5
Exception: reader gone
Traceback:
fib.elv, line 4:
echo $a
fib.elv, line 5:
fib $b (+ $a $b)
fib.elv, line 5:
fib $b (+ $a $b)
fib.elv, line 5:
fib $b (+ $a $b)
fib.elv, line 5:
fib $b (+ $a $b)
fib.elv, line 5:
fib $b (+ $a $b)
fib.elv, line 5:
fib $b (+ $a $b)
fib.elv, line 8:
fib 1 1
Exception: ./fib.elv exited with 2
[tty 120], line 1: ./fib.elv | head -n 5
Well, one nice thing - full stacktrace. But what about that exception? Time for a lesson on Unix.
SIGPIPE
Did you even think how the hell | head -n 5
works anyway? If you have a command that generates a lot of output, and you pipe it into head
, at some point head
will go "OK, I had enough" and exit. Then the command will try to send data to a program that's no longer running, and crash. So why you can do seq 1 1000000 | head -n 5
or cat /usr/share/dict/words | head -n 5
or such?
To support such cases - and pipelines are very important for Unix systems to work - operating system sends SIGPIPE
signal to the program trying to sending data. By default, and that's why all programs written in C and such work so well as Unix commands, this command just tells the program to exit on the spot.
If you want your language so be friendly with Unix utilities, this is also behavior you need. There are really only three modern languages that attempt that - Perl, Ruby, and Raku. Perl (as well as various older languages like Sed, Awk, all other shells, and so on), just don't do anything about SIGPIPE
, which makes them work perfectly out of the box in this situation.
$ perl -le 'print for 1..1_000_000' | head -n 5
1
2
3
4
5
Ruby by default catches SIGPIPE
, and converts sending data to program that exit to an exception, so you cannot do that in Ruby:
$ ruby -e '(1..1_000_000).each{|n| puts n}' | head -n 5
1
2
3
4
5
Traceback (most recent call last):
5: from -e:1:in `<main>'
4: from -e:1:in `each'
3: from -e:1:in `block in <main>'
2: from -e:1:in `puts'
1: from -e:1:in `puts'
-e:1:in `write': Broken pipe @ io_writev - <STDOUT> (Errno::EPIPE)
On the other hand, it is recognized as a common enough use case, so you can tell Ruby to do the traditional thing with trap("PIPE", "EXIT")
:
$ ruby -e 'trap("PIPE", "EXIT"); (1..1_000_000).each{|n| puts n}' | head -n 5
1
2
3
4
5
And because very specific exception is thrown in this case Errno::EPIPE
, you can also catch just that exception while letting all other exceptions happen. So what Ruby is doing is perfectly fine, the default is not very pipeline friendly but there are two very easy ways (trap("PIPE", "EXIT")
or Error::EPIPE
exception) to make your program pipeline friendly.
Raku also converts this to an exception, which is fine default:
$ raku -e 'say $_ for 1..1_000_000' | head -n 5
1
2
3
4
5
Failed to write bytes to filehandle: Broken pipe
in block <unit> at -e line 1
Unhandled exception: Failed to write bytes to filehandle: Broken pipe
at SETTING::src/core.c/Exception.pm6:568 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:<anon>)
from gen/moar/stage2/NQPHLL.nqp:2117 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/nqp/lib/NQPHLL.moarvm:command_eval)
from gen/moar/Compiler.nqp:109 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/lib/Perl6/Compiler.moarvm:command_eval)
from gen/moar/stage2/NQPHLL.nqp:2036 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/nqp/lib/NQPHLL.moarvm:command_line)
from gen/moar/rakudo.nqp:127 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:MAIN)
from gen/moar/rakudo.nqp:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:<mainline>)
from <unknown>:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:<main>)
from <unknown>:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:<entry>)
Unfortunately it doesn't seem to have any workarounds for this. Trapping the SIGTRAP will make it try to write the data second time after the trap, so you get a double exception instead:
$ raku -e 'signal(SIGPIPE).tap:{exit}; say $_ for 1..1_000_000' | head -n 5
1
2
3
4
5
Unhandled exception in code scheduled on thread 4
Failed to write bytes to filehandle: Broken pipe
in block at -e line 1
Unhandled exception: Failed to write bytes to filehandle: Broken pipe
at <unknown>:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/Rakudo/Internals.pm6:1788 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:exit)
from SETTING::src/core.c/Rakudo/Internals.pm6:1796 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:exit)
from SETTING::src/core.c/Rakudo/Internals.pm6:1794 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:exit)
from SETTING::src/core.c/Scheduler.pm6:29 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:handle_uncaught)
from SETTING::src/core.c/ThreadPoolScheduler.pm6:261 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
Failed to write bytes to filehandle: Broken pipe
in block <unit> at -e line 1
in block <unit> at -e line 1
from SETTING::src/core.c/ThreadPoolScheduler.pm6:260 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/ThreadPoolScheduler.pm6:259 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/Rakudo/Internals.pm6:1788 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:exit)
from SETTING::src/core.c/Rakudo/Internals.pm6:1795 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:exit)
from SETTING::src/core.c/Rakudo/Internals.pm6:1794 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:exit)
from -e:1 (<ephemeral file>:)
from SETTING::src/core.c/Supply-factories.pm6:153 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/Supply-factories.pm6:117 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/Lock/Async.pm6:204 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:run-under-recursion-list)
Unhandled exception: Failed to write bytes to filehandle: Broken pipe
from SETTING::src/core.c/Lock/Async.pm6:183 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:run-with-updated-recursion-list)
from SETTING::src/core.c/Lock/Async.pm6:146 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:protect-or-queue-on-recursion)
from SETTING::src/core.c/Supply-factories.pm6:117 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/signals.pm6:55 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
at SETTING::src/core.c/Exception.pm6:568 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:<anon>)
from SETTING::src/core.c/ThreadPoolScheduler.pm6:248 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/ThreadPoolScheduler.pm6:245 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from SETTING::src/core.c/ThreadPoolScheduler.pm6:242 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:run-one)
from gen/moar/stage2/NQPHLL.nqp:2117 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/nqp/lib/NQPHLL.moarvm:command_eval)
from SETTING::src/core.c/ThreadPoolScheduler.pm6:297 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:)
from gen/moar/Compiler.nqp:109 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/lib/Perl6/Compiler.moarvm:command_eval)
from SETTING::src/core.c/Thread.pm6:54 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/CORE.c.setting.moarvm:THREAD-ENTRY)
from gen/moar/stage2/NQPHLL.nqp:2036 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/nqp/lib/NQPHLL.moarvm:command_line)
from gen/moar/rakudo.nqp:127 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:MAIN)
from gen/moar/rakudo.nqp:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:<mainline>)
from <unknown>:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:<main>)
from <unknown>:1 (/usr/local/Cellar/rakudo-star/2021.04/bin/../share/perl6/runtime/perl6.moarvm:<entry>)
If there's a way to do this, it's not in documentation, or anywhere else I checked. This looks like a use case Raku should address, probably doing something similar to what Ruby is doing.
Anyway, back to other languages. If your language really cares about Unix pipelines (that's why I singled out C, Awk, Sed, shells, Perl, Ruby, and Raku, as these do - and all except Raku work or just need a simple switch), instantly hard quitting in this case is fine. For everything else, if the language supports exceptions, it would much rather throw a specific exception, which both lets application deal with the error, and lets the langugae do all the cleanup tasks it might have scheduled.
Just instantly hard quitting when you receive a signal is not something most languages want to do. So in languages that support exceptions, which is most of them, they'd much rather throw an exception, and then do all the proper cleanup and exit properly. So it's totally reasonable for Python to do this:
$ echo -e 'for n in range(1,10000):\n print(n)' | python3 | head -n 5
1
2
3
4
5
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
BrokenPipeError: [Errno 32] Broken pipe
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>
BrokenPipeError: [Errno 32] Broken pipe
It's not even that bad, as it's specific exception BrokenPipeError
, and you can deal with it. Weirdly it's not even that bad to do that in Python:
$ echo -e 'import signal\nsignal.signal(signal.SIGPIPE, signal.SIG_DFL)\nfor n in range(1,10000):\n print(n)' | python3 | head -n 5
1
2
3
4
5
Anyway, somehow Go - which in many ways is a terribly designed language that obviously only got popular due to a certain evil Big Tech company pushing it - also catches SIGPIPE. Even thought it was written by computer equivalent of the Amish, and doesn't even have exceptions in this day and age! And Elvish is written in Go, never reverted this, so it also catches SIGPIPE, and so Elvish programs crash if you | head -n 5
them.
That was a long detour, but that's the story why Elvish is doing the wrong thing.
This is absolutely baffling in a shell, as of all the languages in existence, shell scripts simply have to support being piped to | head -n 5
and such! What's even the point of having Unix shell if you can't do something as simple. It looks like it was reported before, and closed without any fix. As far as I can tell, there's no way to even change that in options in Elvish the way you can in Ruby - there's no interface to operating system signals, and there are no specific exception types in Elvish, so if you wrap it in a try block, it will catch all exceptions.
And Raku should also provide some way to get exit-on-SIGPIPE like Ruby and Python do. It looks like it almost got there, it just needs one more keyword argument to signal
function.
Exception handling
We can't make it autoquit, but we can catch the exception, so let's try just that:
#!/usr/bin/env elvish
fn fib {|a b|
echo $a
fib $b (+ $a $b)
}
try {
fib 1 1
} except e {
echo $e[reason] >&2
}
$ ./fib2.elv | head -n 400 | tail -n 10
2315700212878644019141884587055058551781936851295739770295042651311250305217930509
3746881652193013001948452031301618270087167924331813432662649918207032018665867029
6062581865071657021090336618356676821869104775627553202957692569518282323883797538
9809463517264670023038788649658295091956272699959366635620342487725314342549664567
15872045382336327044129125268014971913825377475586919838578035057243596666433462105
25681508899600997067167913917673267005781650175546286474198377544968911008983126672
41553554281937324111297039185688238919607027651133206312776412602212507675416588777
67235063181538321178464953103361505925388677826679492786974790147181418684399715449
108788617463475645289761992289049744844995705477812699099751202749393926359816304226
176023680645013966468226945392411250770384383304492191886725992896575345044216019675
<unknown reader gone>
The good things - it now works, and Elvish supports big integers by default. Bad thing - there's no way to specify what kind of exception we're catching, it's either all or nothing. If you wrap your program in a try except
block, it will catch all the exceptions, not just EPIPE
/ BrokenPipeError
/ or whatever it would like to be called.
I wanted to write a few more examples, but the SIGPIPE
detour took way too much time.
Should you use Elvish?
Other than completely failing at handling SIGPIPE
, it's a nicer scripting language than shell. But then you know what you could do instead of looking for a nicer shell? Use a real programming language like Ruby or Python or one of so many others.
Well, people just refuse to listen to good advice to stop writing programs in shell. And given that, it makes a lot of sense for people to keep trying to create a "better shell". For that I think Elvish is doing a lot of things right, but it just isn't ready to be treated seriously. Maybe in a few years it will be a serious contender.
Code
All code examples for the series will be in this repository.
Top comments (5)
Sad to see you had a bad first impression. I am rather amazed by it myself.
I assume you didn't spend much time with it and the documentation (this being a speedrun) so just to get some facts right (at least the ones i know):
Just needs to be enabled: elv.sh/ref/readline-binding.html
No, but it has
Directory History
invoked byCTRL-L
which provides a menu completion for every folder you've been to before.I find this the better feature and don't miss autocd myself.
You can see this on the main page under
[3]
.Would't make much sense anyway. If you do this on the command line
elvish
is passed to the completion script as prefix of the current word and you are likely to end up with prefiltered completion candidates.What elvish provides, and this is more consistent, is that when you do
cd <TAB>
you can filter them during menu completion (not limited to start of name).There is. Not having shell completion scripts is a general issue of new shells. Well at least it was.
zzamboni has git completion for elvish and I got a git completer that works in pretty much every shell.
Don't know what exactly you are missing, but documentation is quite extensive.
I agree it's not easy not find yourself around in the beginning (writing documentation is hard), but I find it generally pretty good.
I adheres now to XDG, which at least for us linux folks is pretty normal.
That's boltdb. Pretty famous amoung Go developers. It is a fast and simple key/value database.
Quite a better fit IMO than a relational database like SQLite in this case.
Not in the POSIX style, yes. But it does support aliases.
See here. Starship also supports elvish.
That's true, I think there was a reason against this but I don't really miss it.
There's a nice Quick Tour now showing some correspondence between Elvish and bash syntax.
So I reviewed Elvish the way it comes out of the box, not what it could be customized to eventually. I'm aware that shells and editors can be turned into something totally awesome if you put enough effort into this - since every shell action can call some script, and that script can do all kinds of useful things. I think this new user perspective is more useful.
If there was some
elvish-completion
package I couldbrew install
, I'd do it (Ubuntu hasbash-completion
), but there was nothing like that.Elvish is currently just asking for too much from any potential users:
.profile
). It should either support just enough POSIX syntax for this to work, or come with some importer script.And only then I would be able to really start using it. With a lot of common features still missing, like
<()
, correct sigpipe handling for| head -n 10
,autocd
, etc.The "Comparison with bash" is a good start, but it doesn't cover that much. I think expanding that list would really help any people who want to give Elvish a try.
I can totally believe that Elvish will be good enough to recommend in some time if it fixes these issues, and comes with some decent preset, I'd just not recommend it today.
I just want to mention rbenv, nodenv etc. put real executable shim scripts in $PATH, which makes them compatible with any unix caller, including non-POSIX shells 👍
In contrast some competitor tools (nvm is an example I think) mess with shell aliases/functions, and require extra effort when switching shells 👎
That may be justified in some cases — e.g. I don't see any universal way to implement direnv, it really needs per-shell hooking... It's just that "foo version manager" class of tools can be done robustly by executable shims.
I use default
zsh
so in principle any could work, but in my experiencerbenv
/nodenv
simply cause the least problems, so I use them over alternatives.Just because I stumbled on this right now: seems there is a module providing smart matching (not just start of the name):
github.com/xiaq/edit.elv/blob/mast...