nixpulvis

Building a Shell - Part 1 1456 words published on July 11, 2018.

A shell is one of the best computer interfaces I’ve ever used. Twas declarative, before being declarative was cool. It’s keyboard centric, making it fast, and reliable to use. Sure, a shell isn’t the right tool for everything. I’m not going to try and convince you to do your photo editing by typing lighten(0, 0, 100, 100, 50%). A shell is the right tool for more tasks than many people think (a topic for another day).

This is part 1 of a series I’ll be writing on the creation of oursh, a modern POSIX compatible shell written in Rust. In this post I want to explain what a shell is, and build a very simple one. The shell we create will not be fast, or even good, but it’ll work better than you might think ~50 lines of Ruby would.

Shell Basics

A terminal is a graphical application, or device which generally runs a shell. A shell is a special kind of read eval print loop (REPL), that is primarily concerned with running subprocesses within the operating system.

A shell generally prints a prompt ($ for example) at the start of your terminal’s line before waiting for a command. Using the shell is easy, just type your command and hit return. The shell will execute the command, and after everything’s done return you back to another prompt.

$ echo "Hello World"
Hello World
$ date --iso-8601
2018-07-11
...

You may have heard the saying that everything in UNIX is a file. Well that’s kinda true, but here we’re not reading and writing to a file that’s actually on the filesystem, we’re interacting with STDIN, STDOUT and STDERR. You should think of these like virtual files, which a terminal gives you access to. STDIN is the file descriptor for reading the text you type, while STDOUT and STDERR are both displayed on the terminal when written to.

With the basics under our belt, let’s get started building a very simple, but functional shell. We’ll use Ruby for this, because it’ll make things very easy, as you’ll soon see. If you’re on a Mac you’ve already got Ruby installed, otherwise you can install it easily on many systems. We’ll be working on a program aptly named shell.rb, which you can run with ruby shell.rb from the terminal.

Our Overly Simplistic Ruby Shell

The acronym REPL gives us a starting point for designing our shell, with a wishlist of functions to implement. We can get away without defining print or loop since evaluation will print itself, and loop is built into Ruby.

We’ll start by implementing the read function. This function will block until an entire line is read using gets. A much more powerful shell might read in pieces to update syntax highlighting as you type, or provide various kinds of completion. However, we’re not building anything that fancy at the moment.

# Print a prompt, then read a line from STDIN.
def read
  print('$ ')

  # Try to read a line, exiting if there's no more lines.
  line = STDIN.gets
  if line
    line.chomp.split(' ')
  else
    exit
  end
end

The following implementation of evaluate is a copout, because we just pass the input through to Ruby’s implementation of system which calls /bin/sh under the hood. It does the correct thing however, and is enough to get us off the ground running.

# Run the given command in a subprocess.
def evaluate(argv)
  success = system(*argv)
  unless success
    puts("unknown command '#{argv[0]}'")
  end
end

Now that we’ve defined both read and evaluate, the full blown REPL is a simple one liner.

# The glorious REPL itself.
loop { evaluate(read) }

Running this tiny Ruby program will give you a semi-functional shell. Take a moment and try it out for yourself! There are however, a few critical features missing, for example you’ll notice cd doesn’t work. This is where builtins come into play.

Extending Our Shell (a little)

Builtins are commands that the shell executes directly, without calling out to a subprocess. This is exactly what we need for cd, since we don’t want to change the working directory of a sub-process, we want to update the shell’s working directory itself.

We’ll define a hash of 'command' => lambda { |args| ... } to serve as a lookup table for the builtins. The lambda here is a function which performs the task of the given command.

# Commands to be executed directly by this shell.
BUILTINS = {}

Next we need to update our evaluate function to handle our special builtin commands.

# Run the given command in a subprocess.
def evaluate(argv)
  if BUILTINS[argv[0]]
    BUILTINS[argv[0]].call(argv[1..-1])
  else
    success = system(*argv)
    unless success
      puts("unknown command '#{argv[0]}'")
    end
  end
end

Finally, we can start defining the needed builtins. We’ll start with the two most needed commands, cd and exit, however there are many others which could be defined at this point. Mainly . (or source), alias and exec. I’ll leave those implementations as exercises for the reader.

# The builtin `cd` for changing the shell's working directory.
BUILTINS['cd'] = lambda do |args|
  # Change to the home directory by default.
  args << ENV['HOME'] if args.empty?

  # Read the destination path, doing very basic path expansion.
  dest = args.pop.gsub(/~/, ENV['HOME'])

  # Try to change this shell's working directory.
  begin
    Dir.chdir(dest)
  rescue Exception
    puts("no such directory: #{dest}")
  end
end
# The builtin to exit the shell.
BUILTINS['exit'] = lambda do |args|
  # Exit with a status of 0 by default.
  args << 0 if args.empty?

  # Exit the shell.
  exit args.pop.to_i
end

There’s another small issue the observant reader may have noticed. If you enter ctrl-c our shell process will exit with a nasty Ruby error. This is because by default that sends a SIGINT signal. Signals are another way UNIX processes can communicate with each other. In this case we’ll trap the SIGINT signal, and simply return a new prompt.

# Don't exit on `SIGINT`, instead simply return a new prompt.
trap('INT') { print("\n$ ") }

The Code

If you’d rather download the shell instead of copying / writing it based on the above snippits, I can’t fault you (too much). The pieces are glued together for your pleasure as a Gist.

Conclusion

That’s it, you’ve now got a working shell. In the future, we’ll dive into both the read and evaluate functions in more depth. Both functions need a lot of work before we’re anywhere near having a standalone POSIX shell ready for the “real world”. There’s also many other topics worth exploring, like ENV variables, job control, user groups, glob syntax, locale, and much more.